kit初版,模型引入,agent优化
This commit is contained in:
@@ -4,6 +4,356 @@
|
|||||||
|
|
||||||
## 记录
|
## 记录
|
||||||
|
|
||||||
|
### [CL-20260413-0800] 2026-04-13 08:00 — 修复 InstantStyle ReadTimeout + 多次生成丢失参考图
|
||||||
|
- **tags**: bug修复, Replicate, InstantStyle, timeout, 参考图, Agent Loop, wait, SDK
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/agent/loop.py
|
||||||
|
- **what**: 修复 InstantStyle 首次调用 ReadTimeout (61.4s) + 后续调用丢失参考图返回 "No input, Save money"
|
||||||
|
- **why**: 两个运行时日志证实的独立问题:(1) `wait=60` 时 SDK `_create_prediction_timeout` 生成 `httpx.Timeout(5.0, read=60.5)` 作为 per-request timeout 传给 `httpx.AsyncClient.request()`,优先级高于客户端级 300s timeout;(2) 成功生成后执行 `ref_image_url = None`,但 LLM 可能在后续轮次继续调用工具,此时 InstantStyle 因没有 `style_image` 返回 "No input, Save money"
|
||||||
|
- **decisions**: (1) `wait=60` → `wait=False`:不发 `Prefer: wait` header,create 请求立即返回 prediction ID,SDK 自动进入 `prediction.async_wait()` 轮询路径。轮询请求不带 per-request timeout,使用客户端级 `read=300s`。代价:比 `Prefer: wait` 模式多几秒轮询延迟,但绝不会 timeout。(2) 完全移除 `ref_image_url = None`:消息构建(附加参考图到 LLM 消息)在外层循环前一次性完成,不受影响。ref_image_url 只在 `execute_tool()` 中使用,整个对话期间都应该传递
|
||||||
|
- **notes**: 迭代了 CL-0700 和 CL-0730 的方案。wait 参数完整语义:`True` = `Prefer: wait` + read=60.5s;`int(n)` = `Prefer: wait={n}` + read={n+0.5}s(n 必须 1-60);`False` = 不等待、纯轮询。对慢模型(InstantStyle ~128s)配合大 payload(1.6MB base64),`wait=False` 是唯一可靠选项
|
||||||
|
|
||||||
|
### [CL-20260413-0730] 2026-04-13 07:30 — 修复生图失败后 LLM 重试丢失参考图
|
||||||
|
- **tags**: bug修复, Agent Loop, 参考图, InstantStyle, ref_image_url
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/agent/loop.py
|
||||||
|
- **what**: 修复 LLM 第一次生图工具调用失败后重试时参考图丢失,InstantStyle 返回 "No input, Save money"
|
||||||
|
- **why**: `loop.py` 在每轮工具调用循环结束后无条件执行 `ref_image_url = None`。当第一次调用超时失败后,LLM 决定重试,但此时 ref_image_url 已被清空,第二次工具调用没有参考图。InstantStyle 模型必须有 `style_image` 才能工作
|
||||||
|
- **decisions**: 改为条件性清除:在工具调用循环中标记 `tc_data["_had_images"] = True`,循环结束后检查是否有任何成功产出,只有 `any_success` 时才清除 ref_image_url。失败时保留参考图供后续重试
|
||||||
|
- **notes**: 这个改动对所有参考图模型生效(Kolors、InstantStyle 等)。只要本轮所有工具调用都失败了,ref_image_url 会一直保留直到某次成功为止
|
||||||
|
|
||||||
|
### [CL-20260413-0700] 2026-04-13 07:00 — 修复 InstantStyle 生图 422 + ReadTimeout:wait 参数超限与 SDK 内部 timeout 覆盖
|
||||||
|
- **tags**: bug修复, Replicate, InstantStyle, API参数, async_run, Prefer header, timeout
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- **what**: 修复 InstantStyle 生图 422(`Prefer: wait=x must be 1-60`)和后续 ReadTimeout(61.4s 超时)
|
||||||
|
- **why**: 上次修超时时将 `async_run(wait=300)` 设为 300 秒。Replicate SDK 将 int 类型的 wait 值设为 `Prefer: wait=300` header → API 返回 422。改为 `wait=True` 后 SDK 生成内部 `httpx.Timeout(read=60.5)` 作为请求级 timeout 覆盖了客户端级 300s timeout → 上传 1.6MB base64 style_image + 等待响应超过 60s → ReadTimeout
|
||||||
|
- **decisions**: 最终改为 `wait=60`(API 允许的最大 int 值)。SDK 行为:(1) `Prefer: wait=60` header(合法);(2) 内部 timeout `read=60.5s` 用于初始 create prediction 请求;(3) 60s 内未完成自动 fallback 到 `prediction.async_wait()` 轮询;(4) 轮询请求用客户端级 `httpx.Timeout(read=300s)`
|
||||||
|
- **notes**: Replicate SDK wait 参数语义:`True` = `Prefer: wait` + read=60.5s;`int(n)` = `Prefer: wait={n}` + read={n+0.5}s(n 必须 1-60);`False` = 不等待。对慢模型用 `wait=60` + 长客户端 timeout 是最优组合
|
||||||
|
|
||||||
|
### [CL-20260413-0630] 2026-04-13 06:30 — 修复 Replicate SDK 不走代理 + 超时:自定义 Client 注入代理和长超时
|
||||||
|
- **tags**: bug修复, Replicate, InstantStyle, 代理, httpx, 超时, 图像生成
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- **what**: 修复 InstantStyle 调用时 ConnectTimeout / ReadTimeout + "URL missing protocol" 连环错误
|
||||||
|
- **why**: Replicate Python SDK 的 `_build_httpx_client` 显式传入 `transport=AsyncHTTPTransport()`,导致 httpx 跳过环境变量中的 `HTTPS_PROXY` 代理配置直连 Replicate API。网络不稳定时直连超时,SDK 内部状态异常导致后续请求报 "URL missing protocol"。同时默认 read timeout 30s 不够 InstantStyle 的 ~128s 生成时间
|
||||||
|
- **decisions**: 创建 `_make_replicate_client()` 工厂函数,从环境变量读取代理并注入到 `AsyncHTTPTransport(proxy=...)` 中传给 `ReplicateClient`。read timeout 300s、connect timeout 30s。`_download_image` 也加了代理和 60s 超时
|
||||||
|
- **notes**: Replicate SDK 的代理绕过是个已知设计缺陷(显式 transport 覆盖了 httpx 的代理自动检测)。此修复对所有通过 `_replicate_client` 的 Replicate API 调用生效。无代理环境下也兼容(`proxy=None` 时 httpx 不走代理)
|
||||||
|
|
||||||
|
### [CL-20260413-0600] 2026-04-13 06:00 — 新增 InstantStyle 模型:强风格迁移选项
|
||||||
|
- **tags**: 模型注册, InstantStyle, 风格迁移, IP-Adapter, 模型配置
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- **what**: 在模型注册表中新增 InstantStyle(`jyoung105/instant-style`),专门做风格分离迁移,与 Kolors IP-Adapter 并列为两个参考图模型选项
|
||||||
|
- **why**: Kolors IP-Adapter 的风格迁移弱(内容+风格+构图混合提取),经调研 InstantStyle 是 Replicate 上风格迁移能力最强的可用模型,能从参考图中分离出纯画风(线条、着色、色调)
|
||||||
|
- **decisions**: 利用现有 `supports_ref_image` + `ref_image_param` 抽象机制,InstantStyle 的 `style_image` 参数名通过配置映射,无需修改 `_build_input` 逻辑。默认 `block_mode: "style-only"`(只迁移风格不迁移构图),`style_strength: 1.0`
|
||||||
|
- **notes**: InstantStyle 较贵(~$0.12/次)且慢(~128s),适合风格定调阶段使用;Kolors 保留为低成本快速测试选项。社区模型需版本 hash
|
||||||
|
|
||||||
|
### [CL-20260413-0540] 2026-04-13 05:40 — 非 vision LLM + 参考图时禁止猜测风格,避免 prompt 与参考图风格冲突
|
||||||
|
- **tags**: prompt 工程, IP-Adapter, 风格一致性, system prompt, 非vision模型
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/agent/loop.py
|
||||||
|
- **what**: 修改 system prompt 和非 vision 模型的参考图文字提示,明确要求 LLM 不要猜测/指定画风关键词,风格完全交给 IP-Adapter 从参考图提取
|
||||||
|
- **why**: DeepSeek 等非 vision LLM 看不到参考图内容,会根据"2D 游戏"等关键词脑补风格(如 pixel art),导致 prompt 中的风格描述与参考图实际风格严重冲突。IP-Adapter 优先服从 prompt 的显式风格指令,参考图的风格影响被稀释
|
||||||
|
- **decisions**: 双重约束——system prompt 中加入"看不到参考图时不猜风格"的通用指引 + 注入的文字提示中用编号列表明确三条规则。保留了 vision 模型路径不变(vision 模型能看图,可以写风格词)
|
||||||
|
- **notes**: 这是过渡方案,等切换到 vision LLM(如 GPT-4o)后,LLM 可直接看图描述风格,此约束自动失效(走 vision_capable 分支)
|
||||||
|
|
||||||
|
### [CL-20260413-0520] 2026-04-13 05:20 — 多图生成改为单次 API 调用 + 修复空错误信息
|
||||||
|
- **tags**: 性能优化, Replicate, 速率限制, 错误处理, 模型配置
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- **what**: 多图生成从 for 循环多次 API 调用改为单次调用(利用模型原生批量参数),同时修复异常 str() 为空时的错误信息丢失
|
||||||
|
- **why**: LLM 传 num_images=3 时会发 3 个独立 API 请求,在低余额账户的严格速率限制(burst=1)下后续请求会被 429 拦截。改为单次调用后只消耗 1 次请求配额。空错误信息(`[生成失败: ]`)导致用户无法诊断失败原因
|
||||||
|
- **decisions**: 新增 `num_images_param` 配置字段(Flux 用 `num_outputs`,Kolors 用 `number_of_images`),由 `_build_input` 统一注入。从 kolors 的 default_params 中移除了硬编码的 `number_of_images: 1`,改为动态设置
|
||||||
|
- **notes**: 空异常现在显示为 `{TypeName}: {repr(e)}`,确保始终有诊断信息
|
||||||
|
|
||||||
|
### [CL-20260413-0500] 2026-04-13 05:00 — 生成图片后显示使用的模型名称
|
||||||
|
- **tags**: 模型溯源, SSE, 图片生成, 前端展示
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/agent/tools.py
|
||||||
|
- art-agent/backend/app/agent/loop.py
|
||||||
|
- art-agent/frontend/src/lib/types.ts
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- art-agent/frontend/src/components/chat/chat-messages.tsx
|
||||||
|
- **what**: 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),失败时也附带模型名称
|
||||||
|
- **why**: 用户无法确认前端选择的模型是否真的被用于生图,特别是在多模型切换场景下需要明确溯源
|
||||||
|
- **decisions**: `generate_images` 返回值从 `list[str]` 改为 `GenerateResult` 数据类(包含 urls + model_name + model_id),模型名称通过 SSE `image_result` 和 `tool_error` 事件透传到前端。ChatMessage 类型新增 `modelName` 可选字段,消息归档后仍可查看模型来源
|
||||||
|
- **notes**: 模型名称来自 config.py 注册表中的 `name` 字段,所以显示的是友好名称而非 Replicate 的 model_id
|
||||||
|
|
||||||
|
### [CL-20260413-0440] 2026-04-13 04:40 — 修复 Kolors IP-Adapter 模型 404 错误:补全 Replicate 版本 hash
|
||||||
|
- **tags**: bug修复, Replicate, Kolors, IP-Adapter, 模型配置
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- **what**: 在 kolors-ipadapter 的 model_id 中补全版本 hash,从 `fofr/kolors-with-ipadapter` 改为 `fofr/kolors-with-ipadapter:5a1a92b2...`
|
||||||
|
- **why**: Replicate SDK `async_run` 对非 official 模型(社区模型)只用 `owner/model` 格式调用时,会尝试 official model predictions 端点,该端点返回 404。必须使用 `owner/model:version_hash` 格式才能正确创建 prediction
|
||||||
|
- **decisions**: 只修复 kolors 模型(唯一报错的),其他三个模型(flux-schnell、flux-dev、sdxl)均为 official/热门模型,`owner/model` 格式能正常工作
|
||||||
|
- **notes**: 未来新增社区模型时,需确保 model_id 包含版本 hash。经实测加版本 hash 后生图成功
|
||||||
|
|
||||||
|
### [CL-20260413-0430] 2026-04-13 04:30 — 图片生成失败时将具体错误信息透传到前端
|
||||||
|
- **tags**: 错误处理, Agent Loop, SSE, 调试体验
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/agent/loop.py
|
||||||
|
- art-agent/frontend/src/app/page.tsx
|
||||||
|
- art-agent/frontend/src/lib/api.ts
|
||||||
|
- **what**: 新增 `tool_error` SSE 事件类型,将图片生成工具的具体错误信息(如 Replicate API 报错详情)直接透传到前端展示
|
||||||
|
- **why**: 之前工具调用失败时,错误信息只传回给 LLM,LLM 会自行"翻译"错误(如说成"速率限制"),用户无法看到真实报错,排查困难。典型场景:使用 Kolors IP-Adapter 生图失败,前端只看到 LLM 的模糊描述,无法定位是参数错误、超时还是 API 限制
|
||||||
|
- **decisions**: 错误信息同时发给前端展示和 LLM 继续对话,不互斥。前端用 ⚠️ 前缀醒目显示错误详情
|
||||||
|
- **notes**: 此改动不影响 LLM 的重试机制(工具结果仍然回传给 LLM),只是额外增加了一条面向用户的 SSE 事件
|
||||||
|
|
||||||
|
### [CL-20260413-0330] 2026-04-13 03:30 — README 补充访问地址汇总 + 穿透管理文档
|
||||||
|
- **tags**: 文档, README, 穿透, Cloudflare, cloudflared
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/README.md (新增"访问地址"章节 + 重写"远程访问"章节)
|
||||||
|
- **what**: README 新增三种访问场景(本机/局域网/外网)的地址汇总表,重写远程访问章节补充 cloudflared 安装指引、穿透管理操作表、代理配置排错
|
||||||
|
- **why**: 原 README 远程访问章节过于简略,缺少 cloudflared 安装方式、穿透的启动/停止/排错说明,用户实际操作时需要这些信息
|
||||||
|
- **decisions**:
|
||||||
|
- 访问地址章节放在"使用"之后、"配置说明"之前,让用户启动后立即看到访问方式
|
||||||
|
- cloudflared 安装提供 winget 和手动下载两种方式(winget 安装后可能不在 PATH 中)
|
||||||
|
- 穿透管理用表格呈现(启动/停止/查看状态/重启),简洁直观
|
||||||
|
- 代理排错放在注意事项中,因为国内环境常需代理才能连上 Cloudflare
|
||||||
|
- **notes**: 无
|
||||||
|
|
||||||
|
### [CL-20260412-2345] 2026-04-12 23:45 — 参考图上传独立化:进度条 + 状态反馈 + 失败重试
|
||||||
|
- **tags**: 前端, 后端, 参考图, 上传, UX, 进度反馈
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/api/chat.py (新增 POST /api/upload-ref-image 独立上传端点,chat 端点新增 ref_image_url 字段)
|
||||||
|
- art-agent/frontend/src/lib/api.ts (新增 uploadRefImage 函数,XMLHttpRequest 实现进度回调;sendChat 参数从 File 改为服务端路径字符串)
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx (重写上传流程:选图即上传、环形进度条、状态角标、失败重试、input value 重置)
|
||||||
|
- art-agent/frontend/src/app/page.tsx (handleSend 适配新签名:接收服务端路径而非 File;标注截图也走独立上传)
|
||||||
|
- **what**: 将参考图从"随消息一起提交"改为"选中后立即独立上传",全程有进度条、状态提示、失败重试入口
|
||||||
|
- **why**: 原方案图片上传绑定在发送消息时才执行,无任何进度反馈。大图或走 Cloudflare Tunnel 时用户完全无法判断上传是否开始、是否成功、是否卡住。多次上传参考图后 file input 不重置也导致交互异常
|
||||||
|
- **decisions**: 使用 XMLHttpRequest 而非 fetch(fetch 不支持 upload progress 事件);后端同时保留 ref_image 文件上传和 ref_image_url 路径两种方式,向后兼容
|
||||||
|
- **notes**: 上传超时设为 120 秒;支持 AbortController 取消;file input 每次选择后立即重置 value,确保同一文件可重复选择
|
||||||
|
|
||||||
|
### [CL-20260412-2315] 2026-04-12 23:15 — 修复 Cloudflare Tunnel 跨域开发警告
|
||||||
|
- **tags**: 前端, 配置, Next.js, Cloudflare, 跨域
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/next.config.ts (新增 allowedDevOrigins)
|
||||||
|
- **what**: 在 next.config.ts 中添加 `allowedDevOrigins: ["*.trycloudflare.com"]`,消除 Cloudflare Tunnel 穿透时的跨域警告
|
||||||
|
- **why**: Next.js 检测到来自 trycloudflare.com 的跨域请求,发出警告提示未来版本将强制要求配置
|
||||||
|
- **decisions**: 使用通配符 `*.trycloudflare.com` 覆盖所有 Quick Tunnel 随机域名
|
||||||
|
- **notes**: 仅影响开发模式,生产构建不受影响
|
||||||
|
|
||||||
|
### [CL-20260412-2300] 2026-04-12 23:00 — 集成 Kolors IP-Adapter 模型,参考图可直接用于风格生成
|
||||||
|
- **tags**: 后端, 前端, IP-Adapter, 风格迁移, 参考图, 模型注册, Replicate, 架构
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/config.py (新增 kolors-ipadapter 模型注册 + supports_ref_image / ref_image_param 通用字段)
|
||||||
|
- art-agent/backend/app/services/image_gen.py (ReplicateProvider._build_input 支持参考图注入 + to_data_uri 公共工具函数)
|
||||||
|
- art-agent/backend/app/agent/loop.py (复用 to_data_uri 替代原 _to_vision_url,移除冗余 import)
|
||||||
|
- art-agent/frontend/src/lib/types.ts (ImageModelInfo 新增 supports_ref_image 字段)
|
||||||
|
- art-agent/frontend/src/components/chat/model-selector.tsx (模型列表中显示"参考图"标记)
|
||||||
|
- **what**: 集成 Kolors IP-Adapter 模型(fofr/kolors-with-ipadapter),参考图可真正参与图像生成的风格控制;架构上为后续切换其他 IP-Adapter 模型预留了通用扩展机制
|
||||||
|
- **why**: 之前参考图仅由 LLM 侧理解(且 DeepSeek 看不到图),生图工具侧完全忽略了参考图。用户希望参考图能真正影响生成结果的风格
|
||||||
|
- **decisions**:
|
||||||
|
- 模型注册表新增两个通用字段:supports_ref_image(布尔标记)和 ref_image_param(参考图参数名,不同模型可能不同)
|
||||||
|
- _build_input 根据 supports_ref_image 自动走不同构建逻辑,新增 IP-Adapter 模型只需加注册表配置
|
||||||
|
- 参考图通过 base64 data URI 传递给 Replicate(避免本地 URL 不可达的问题)
|
||||||
|
- to_data_uri 抽为 image_gen.py 中的公共函数,loop.py 复用(替代原 _to_vision_url)
|
||||||
|
- Kolors IP-Adapter 默认 ip_adapter_weight=0.8(非 1.0),给 prompt 文字留一些影响空间
|
||||||
|
- 前端模型列表中为支持参考图的模型显示绿色"参考图"徽章
|
||||||
|
- get_image_models_list 返回 supports_ref_image 字段,前端据此渲染标记
|
||||||
|
- **notes**:
|
||||||
|
- Kolors IP-Adapter 成本极低(~$0.004/次),速度快(~4s)
|
||||||
|
- 后续要换其他 IP-Adapter 模型(如 Flux IP-Adapter v2),只需在 IMAGE_MODELS 中新增配置
|
||||||
|
- 分层组合产线方案(IP-Adapter → LoRA → ControlNet)已记录为延期方案 [style-pipeline-layered]
|
||||||
|
|
||||||
|
### [CL-20260412-2230] 2026-04-12 22:30 — 用户消息气泡中显示参考图缩略图
|
||||||
|
- **tags**: 前端, 参考图, UX, chat-messages
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/lib/types.ts (ChatMessage 新增 refImageUrl 字段)
|
||||||
|
- art-agent/frontend/src/app/page.tsx (构造 userMessage 时保存参考图 Blob URL)
|
||||||
|
- art-agent/frontend/src/components/chat/chat-messages.tsx (用户消息气泡中渲染参考图缩略图)
|
||||||
|
- **what**: 用户上传参考图发送后,在聊天气泡中显示参考图缩略图,让用户确认图片确实被附带发送了
|
||||||
|
- **why**: 之前用户上传参考图后,发送消息后气泡中看不到参考图,无法确认是否上传成功,体验不好
|
||||||
|
- **decisions**:
|
||||||
|
- 使用 URL.createObjectURL 创建 Blob URL 用于当前会话内显示(轻量、即时)
|
||||||
|
- Blob URL 不持久化到 localStorage(刷新后失效),避免存储 base64 data URI 的空间开销
|
||||||
|
- 参考图缩略图显示在消息文本上方,最大尺寸 160×120px
|
||||||
|
- **notes**:
|
||||||
|
- Blob URL 仅在当前标签页有效,刷新页面后旧消息中的参考图缩略图不可见(MVP 可接受)
|
||||||
|
- 如需持久化,后续可改为存储后端上传路径(需要等后端返回 upload URL 后再构造 userMessage)
|
||||||
|
|
||||||
|
### [CL-20260412-2200] 2026-04-12 22:00 — 修复 DeepSeek 不支持 image_url 导致参考图上传报错
|
||||||
|
- **tags**: bug修复, 后端, Agent Loop, vision, DeepSeek, 多模态
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/agent/loop.py (新增 vision 能力检测,非 vision 模型走文字提示)
|
||||||
|
- **what**: 修复使用 DeepSeek 作为 LLM 时,上传参考图后报 400 错误 `unknown variant 'image_url', expected 'text'`
|
||||||
|
- **why**: 代码原本假设所有 LLM 都支持 OpenAI Vision API 的 `image_url` content type,但 DeepSeek 的 Chat API 只支持 `text` 类型
|
||||||
|
- **decisions**:
|
||||||
|
- 通过模型名称关键词检测 vision 能力(gpt-4o / gpt-4-vision / claude 视为支持)
|
||||||
|
- 不支持 vision 的模型:用文字提示告知 LLM 有参考图存在,参考图仍由图像生成工具侧处理
|
||||||
|
- 支持 vision 的模型:保持原有 image_url 格式不变
|
||||||
|
- **notes**:
|
||||||
|
- 此方案确保参考图功能在任何 LLM 下都不会报错
|
||||||
|
- 非 vision 模型无法"看到"参考图,但生图工具仍会收到参考图用于 img2img 场景
|
||||||
|
- 如需更精确的 vision 能力检测,可后续改为在 config.py 中按模型注册 vision 标志
|
||||||
|
|
||||||
|
### [CL-20260412-2130] 2026-04-12 21:30 — 修复图片生成失败时前端显示破碎图标
|
||||||
|
- **tags**: bug修复, 前端, 后端, 图像生成, 错误处理
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/agent/tools.py (分离有效 URL 和错误信息)
|
||||||
|
- art-agent/frontend/src/app/page.tsx (过滤无效 URL,空列表时不渲染图片网格)
|
||||||
|
- **what**: 修复 Replicate API 失败时前端出现 4 个破碎图标框的问题
|
||||||
|
- **why**: 用户在 Replicate 余额不足(402)时生图,错误字符串被当作图片 URL 传给 `<img>` 标签
|
||||||
|
- **decisions**:
|
||||||
|
- 后端 tools.py:将 generate_images 返回值分为 images(有效 URL)和 errors(错误信息)两个字段
|
||||||
|
- 前端 page.tsx:过滤以 `[` 开头的无效 URL,无有效图片时直接 break 不创建 ImageAsset
|
||||||
|
- **notes**:
|
||||||
|
- 根因是 Replicate 账户余额不足,需用户到 https://replicate.com/account/billing#billing 充值
|
||||||
|
- 此修复确保任何 API 错误场景下前端都不会显示破碎图标
|
||||||
|
|
||||||
|
### [CL-20260412-2100] 2026-04-12 21:00 — 生图模型动态切换:Provider 抽象 + 前端模型选择器
|
||||||
|
- **tags**: 后端, 前端, 图像生成, 模型切换, Provider, 重构, Replicate
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/config.py (新增 IMAGE_MODELS 注册表 + get_image_model_config / get_image_models_list / get_default_image_model_id)
|
||||||
|
- art-agent/backend/app/services/image_gen.py (重构:ImageProvider 抽象基类 + ReplicateProvider 实现 + generate_images 接受 model_id)
|
||||||
|
- art-agent/backend/app/api/chat.py (新增 GET /api/models 端点 + POST /api/chat 增加 image_model 参数)
|
||||||
|
- art-agent/backend/app/agent/loop.py (run_agent_loop 增加 image_model 参数透传)
|
||||||
|
- art-agent/backend/app/agent/tools.py (execute_tool 增加 image_model 参数透传)
|
||||||
|
- art-agent/backend/.env (IMAGE_MODEL 改为短 ID 格式)
|
||||||
|
- art-agent/backend/.env.example (同步更新)
|
||||||
|
- art-agent/frontend/src/lib/types.ts (新增 ImageModelInfo 类型)
|
||||||
|
- art-agent/frontend/src/lib/api.ts (新增 fetchModels() + sendChat 增加 imageModel 参数)
|
||||||
|
- art-agent/frontend/src/components/chat/model-selector.tsx (新建:模型选择下拉组件)
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx (集成 ModelSelector + onSend 签名扩展)
|
||||||
|
- art-agent/frontend/src/app/page.tsx (handleSend 透传 imageModel 到 sendChat)
|
||||||
|
- **what**: 将图像生成服务从 Replicate 硬绑定重构为 Provider 抽象架构,支持 Replicate 上多模型(flux-schnell / flux-dev / SDXL)按消息粒度切换
|
||||||
|
- **why**: 用户希望能在对话中随时切换生图模型来对比不同模型的效果
|
||||||
|
- **decisions**:
|
||||||
|
- Provider 模式:抽象基类 ImageProvider + 具体实现(当前只有 ReplicateProvider),预留 DalleProvider/ComfyUIProvider 等扩展点
|
||||||
|
- 模型注册表放在 config.py 中(Python dict),新增模型只需加一条配置
|
||||||
|
- 前端模型列表由后端 API 驱动(GET /api/models),不在前端硬编码
|
||||||
|
- 切换粒度为每条消息级(最灵活),通过 FormData 传递 image_model 参数
|
||||||
|
- 模型选择持久化到 localStorage,作为后续消息的默认值
|
||||||
|
- Flux 系列使用 aspect_ratio 参数,SDXL 使用 width/height 参数,由 ReplicateProvider._build_input 内部分流处理
|
||||||
|
- **notes**:
|
||||||
|
- 向后兼容:image_model 参数可选,不传时使用 .env 中的默认模型
|
||||||
|
- .env 中 IMAGE_MODEL 支持短 ID(flux-schnell)和完整 Replicate ID(black-forest-labs/flux-schnell),自动转换
|
||||||
|
|
||||||
|
### [CL-20260412-1800] 2026-04-12 18:00 — 移动端适配 + Cloudflare Tunnel 内网穿透
|
||||||
|
- **tags**: 前端, 响应式, 移动端, 部署, Cloudflare, 穿透, 配置
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/app/layout.tsx (viewport meta 配置)
|
||||||
|
- art-agent/frontend/src/app/globals.css (iOS bounce 防护、tap highlight、dvh 支持、遮罩动画)
|
||||||
|
- art-agent/frontend/src/app/page.tsx (桌面端展开按钮加 hidden md:flex)
|
||||||
|
- art-agent/frontend/src/app/gallery/page.tsx (网格 gap/列数适配、hover 操作移动端始终可见、选择框可见)
|
||||||
|
- art-agent/frontend/src/components/sidebar/sidebar.tsx (移动端固定定位抽屉 + 遮罩)
|
||||||
|
- art-agent/frontend/src/components/layout/top-nav.tsx (新增汉堡菜单按钮、间距微调)
|
||||||
|
- art-agent/frontend/src/components/detail/image-detail-panel.tsx (移动端全屏 overlay)
|
||||||
|
- art-agent/frontend/src/components/chat/chat-messages.tsx (气泡宽度 90%、间距收紧)
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx (padding 收紧)
|
||||||
|
- art-agent/frontend/src/components/chat/image-grid.tsx (max-width 适配、操作栏移动端可见)
|
||||||
|
- art-agent/frontend/src/lib/app-context.tsx (小屏默认折叠侧边栏、切换会话自动收起)
|
||||||
|
- art-agent/frontend/next.config.ts (trycloudflare.com + replicate.delivery 白名单)
|
||||||
|
- art-agent/start-tunnel.ps1 (新建:一键穿透脚本)
|
||||||
|
- art-agent/README.md (新增远程访问章节、uvicorn 加 --host 0.0.0.0)
|
||||||
|
- **what**: 为实现手机浏览器远程访问,完成移动端响应式适配(9 个组件)和 Cloudflare Quick Tunnel 穿透方案
|
||||||
|
- **why**: 用户希望把项目发给自己和朋友在手机上通过浏览器使用
|
||||||
|
- **decisions**:
|
||||||
|
- 移动端断点统一用 `md:` (768px),与 Tailwind 默认保持一致
|
||||||
|
- 侧边栏移动端用 fixed + overlay 抽屉模式,桌面端保持原有 w-0/w-[280px] 切换
|
||||||
|
- 图片详情面板移动端全屏(fixed inset-0),不做半屏,因为手机屏幕空间有限
|
||||||
|
- 穿透方案选 Cloudflare Quick Tunnel(免费、免注册),不选 ngrok(有连接限制)
|
||||||
|
- 穿透脚本自动管理 .env.local 的更新和恢复,减少手动操作
|
||||||
|
- hover 操作在移动端始终显示(无 hover 事件),避免操作不可达
|
||||||
|
- 路线 B(云服务器正式部署)记录为延期方案,待后续实施
|
||||||
|
- **notes**:
|
||||||
|
- 构建验证通过,无 lint 错误
|
||||||
|
- cloudflared 通过 winget 安装,版本 2025.8.1
|
||||||
|
- Quick Tunnel 每次启动分配随机域名(xxx.trycloudflare.com),非固定
|
||||||
|
- 穿透启动后需重启前端才能读取新的 NEXT_PUBLIC_API_URL
|
||||||
|
- project-launcher Skill 中的 uvicorn 命令也同步加了 --host 0.0.0.0
|
||||||
|
|
||||||
|
### [CL-20260412-1530] 2026-04-12 15:30 — 修复 .env 配置加载时序问题
|
||||||
|
- **tags**: 后端, bug修复, 配置, dotenv, Python
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/backend/app/main.py (load_dotenv 前置 + override=True)
|
||||||
|
- art-agent/backend/app/config.py (模块级常量 → 函数式懒读取)
|
||||||
|
- art-agent/backend/app/agent/loop.py (引用改为函数调用)
|
||||||
|
- art-agent/backend/app/services/image_gen.py (引用改为函数调用)
|
||||||
|
- art-agent/backend/.env (OPENAI_BASE_URL 去掉 /v1)
|
||||||
|
- **what**: 修复切换 DeepSeek API 后报 "Model Not Exist" 400 错误
|
||||||
|
- **why**: 用户修改 .env 切换到 DeepSeek,但实际发出的请求仍使用默认模型名 gpt-4o-mini
|
||||||
|
- **decisions**:
|
||||||
|
- load_dotenv(override=True) 放在 main.py 所有业务 import 之前,确保环境变量在任何模块读取前就位
|
||||||
|
- config.py 从模块级常量改为函数(get_llm_model() 等),避免 import 时求值被固化
|
||||||
|
- 不使用 lru_cache(避免首次调用在 load_dotenv 前就缓存了空值)
|
||||||
|
- **notes**:
|
||||||
|
- 根因有两层:import 时序(config.py 在 load_dotenv 前被 import)+ override 默认行为(不覆盖已有环境变量)
|
||||||
|
- 通过在 uvicorn 同环境中运行 Python 脚本成功复现了 API 调通,证明问题在进程内加载顺序而非 API 本身
|
||||||
|
- DeepSeek base_url 使用 https://api.deepseek.com(不带 /v1),与官方文档一致
|
||||||
|
|
||||||
|
### [CL-20260412-1500] 2026-04-12 15:00 — 品牌重命名 EPEEKit + API 配置集中化
|
||||||
|
- **tags**: EPEEKit, 品牌, 配置, 后端, 前端, 重构
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/app/layout.tsx (title/description)
|
||||||
|
- art-agent/frontend/src/components/layout/top-nav.tsx (Logo SVG + 品牌名)
|
||||||
|
- art-agent/frontend/src/app/page.tsx (欢迎文案)
|
||||||
|
- art-agent/frontend/src/lib/store.ts (localStorage 键名)
|
||||||
|
- art-agent/frontend/src/app/gallery/page.tsx (下载前缀)
|
||||||
|
- art-agent/frontend/src/components/detail/image-detail-panel.tsx (下载前缀)
|
||||||
|
- art-agent/frontend/src/components/chat/image-grid.tsx (下载前缀)
|
||||||
|
- art-agent/frontend/package.json (项目名)
|
||||||
|
- art-agent/frontend/package-lock.json (项目名)
|
||||||
|
- art-agent/backend/app/main.py (FastAPI title)
|
||||||
|
- art-agent/backend/app/config.py (新建:集中配置)
|
||||||
|
- art-agent/backend/app/agent/loop.py (引用集中配置)
|
||||||
|
- art-agent/backend/app/services/image_gen.py (引用集中配置 + 简化重复分支)
|
||||||
|
- art-agent/backend/.env (重组为分组结构 + 新增 LLM_MODEL/IMAGE_MODEL 等)
|
||||||
|
- art-agent/backend/.env.example (新建)
|
||||||
|
- art-agent/README.md (全文更新)
|
||||||
|
- **what**: 将应用从 "Art Agent" 正式命名为 "EPEEKit",同时将后端所有硬编码的 API/模型配置抽取到 .env 文件,通过 config.py 集中管理
|
||||||
|
- **why**: 用户要求正式命名应用并希望能方便地切换 LLM 和图像生成模型进行测试
|
||||||
|
- **decisions**:
|
||||||
|
- 品牌名全部统一为 "EPEEKit",包括页面标题、Logo、下载文件名、localStorage 键名
|
||||||
|
- Logo 采用内联 SVG 设计(主题色圆角方块 + A/i 组合),不引入外部图片资源
|
||||||
|
- API 配置走 .env + config.py 方案(非 settings.py 类模式),保持极简
|
||||||
|
- 切换 LLM 只需改 .env 中 3 个值:OPENAI_BASE_URL + OPENAI_API_KEY + LLM_MODEL
|
||||||
|
- image_gen.py 中去除了参考图分支的重复代码(两个分支逻辑完全相同)
|
||||||
|
- **notes**:
|
||||||
|
- localStorage 键名变更意味着旧数据不会自动迁移(MVP 阶段可接受)
|
||||||
|
- .env 文件中含真实密钥,需确认 .gitignore 覆盖
|
||||||
|
|
||||||
|
### [CL-20260412-0300] 2026-04-12 03:00 — 完成交互原型全面重构
|
||||||
|
- **tags**: art-agent, 前端, 交互设计, 三栏布局, 标签系统, Canvas标注, 资源库, 后端
|
||||||
|
- **affected_files**:
|
||||||
|
- art-agent/frontend/src/app/page.tsx (重写)
|
||||||
|
- art-agent/frontend/src/app/layout.tsx (添加 AppProvider)
|
||||||
|
- art-agent/frontend/src/app/globals.css (增加样式)
|
||||||
|
- art-agent/frontend/src/app/gallery/page.tsx (新建)
|
||||||
|
- art-agent/frontend/src/lib/types.ts (新建:全局类型定义)
|
||||||
|
- art-agent/frontend/src/lib/store.ts (新建:localStorage 持久化存储)
|
||||||
|
- art-agent/frontend/src/lib/app-context.tsx (新建:全局状态 Context)
|
||||||
|
- art-agent/frontend/src/lib/api.ts (更新类型兼容)
|
||||||
|
- art-agent/frontend/src/components/layout/top-nav.tsx (新建)
|
||||||
|
- art-agent/frontend/src/components/sidebar/sidebar.tsx (新建)
|
||||||
|
- art-agent/frontend/src/components/sidebar/session-list.tsx (新建)
|
||||||
|
- art-agent/frontend/src/components/sidebar/tag-filter.tsx (新建)
|
||||||
|
- art-agent/frontend/src/components/chat/chat-messages.tsx (适配新类型)
|
||||||
|
- art-agent/frontend/src/components/chat/chat-input.tsx (增加拖拽上传)
|
||||||
|
- art-agent/frontend/src/components/chat/image-grid.tsx (增加操作栏)
|
||||||
|
- art-agent/frontend/src/components/detail/image-detail-panel.tsx (新建)
|
||||||
|
- art-agent/frontend/src/components/detail/annotation-canvas.tsx (新建)
|
||||||
|
- art-agent/backend/app/agent/loop.py (System Prompt + base64 转换)
|
||||||
|
- **what**: 从单页对话 MVP 全面重构为完整交互原型,三个 Phase 一次性完成
|
||||||
|
- **why**: 用户要求完整设计交互原型,通过规划讨论确定了三栏布局 + 标签系统 + 标注 + 资源库的完整方案
|
||||||
|
- **decisions**:
|
||||||
|
- 数据持久化使用 localStorage(MVP 阶段),后续可迁移到后端数据库
|
||||||
|
- 全局状态管理使用 React Context + useReducer 模式,不引入第三方状态库
|
||||||
|
- 标注功能使用原生 Canvas API,不引入第三方画板库(减少依赖)
|
||||||
|
- 标注坐标使用归一化比例(0-1),与图片实际分辨率解耦
|
||||||
|
- 资源库使用 Modal overlay 展示详情,而非独立路由页面
|
||||||
|
- 标注截图通过 base64 data URI 直接传给 OpenAI Vision,后端自动处理本地路径转换
|
||||||
|
- **notes**:
|
||||||
|
- 新增 12 个源文件,修改 6 个现有文件,无新依赖引入
|
||||||
|
- 内置标签 6 个:UI、Icon、原画、风格探索、立绘、概念图
|
||||||
|
- 标注工具 4 种:矩形框选、箭头、自由画笔、文字,含撤销/清除
|
||||||
|
- 构建验证通过,无 linter 错误
|
||||||
|
- 未来可优化:标签自动推荐逻辑(当前仅预留接口)、后端会话持久化、图片搜索索引
|
||||||
|
|
||||||
### [CL-20260411-1500] 2026-04-11 15:00 — 完成美术 Agent 产品决策和技术选型
|
### [CL-20260411-1500] 2026-04-11 15:00 — 完成美术 Agent 产品决策和技术选型
|
||||||
- **tags**: art-agent, 产品决策, 技术选型, 规划
|
- **tags**: art-agent, 产品决策, 技术选型, 规划
|
||||||
- **affected_files**:
|
- **affected_files**:
|
||||||
|
|||||||
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。
|
||||||
|
|
||||||
|
- [CL-20260413-0800] 修复 InstantStyle ReadTimeout + 多次生成丢失参考图:wait 改 False 走纯轮询 + 不再清除 ref_image_url
|
||||||
|
- [CL-20260413-0730] 修复生图失败后 LLM 重试丢失参考图:ref_image_url 改为仅在成功产出图片后才清除
|
||||||
|
- [CL-20260413-0700] 修复 InstantStyle 生图 422 + ReadTimeout:`wait=300` 超限改为 `wait=60`,SDK 超时自动 fallback 轮询
|
||||||
|
- [CL-20260413-0630] 修复 Replicate SDK 不走代理 + 超时不够:注入 HTTPS_PROXY 到 transport + read 300s / connect 30s,解决 ConnectTimeout 连环错误
|
||||||
|
- [CL-20260413-0600] 新增 InstantStyle 模型(强风格迁移),与 Kolors 并列为参考图模型选项,block_mode=style-only
|
||||||
|
- [CL-20260413-0540] 非 vision LLM + 参考图时禁止猜测风格,prompt 只描述内容不写画风,风格交由 IP-Adapter 处理
|
||||||
|
- [CL-20260413-0520] 多图生成改为单次 API 调用(利用模型原生批量参数),避免速率限制 + 修复空错误信息显示
|
||||||
|
- [CL-20260413-0500] 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),便于模型溯源
|
||||||
|
- [CL-20260413-0440] 修复 Kolors IP-Adapter 404 错误:社区模型需 `owner/model:version_hash` 格式,补全版本 hash 后生图成功
|
||||||
|
- [CL-20260413-0430] 图片生成失败时将具体错误信息(Replicate API 报错等)透传到前端展示,新增 tool_error SSE 事件
|
||||||
|
- [CL-20260413-0330] README 补充访问地址汇总(本机/局域网/外网)+ 穿透管理文档(安装/启停/排错)
|
||||||
|
- [CL-20260412-2345] 参考图上传独立化:选图即上传 + 环形进度条 + 成功/失败状态反馈 + 失败重试,后端新增独立上传端点
|
||||||
|
- [CL-20260412-2315] 修复 Cloudflare Tunnel 跨域开发警告:next.config.ts 新增 allowedDevOrigins 通配符
|
||||||
|
- [CL-20260412-2300] 集成 Kolors IP-Adapter 模型:参考图可直接用于风格生成,模型注册表新增通用参考图支持机制
|
||||||
|
- [CL-20260412-2230] 用户消息气泡中显示参考图缩略图:ChatMessage 新增 refImageUrl,发送后在气泡中渲染参考图
|
||||||
|
- [CL-20260412-2200] 修复 DeepSeek 不支持 image_url 参考图上传报错:新增 vision 能力检测,非 vision 模型走文字提示
|
||||||
|
- [CL-20260412-2130] 修复图片生成失败时前端显示破碎图标:后端分离错误信息与有效 URL,前端过滤无效 URL 不渲染空图片
|
||||||
|
- [CL-20260412-2100] 生图模型动态切换:Provider 抽象架构 + 前端模型选择器 + Replicate 多模型(flux-schnell/flux-dev/SDXL)按消息粒度切换
|
||||||
|
- [CL-20260412-1800] 移动端适配 + Cloudflare Tunnel 内网穿透:响应式布局改造(9 个组件)+ 一键穿透脚本 + cloudflared 安装
|
||||||
|
- [CL-20260412-1530] 修复 .env 配置加载时序问题:load_dotenv 前置 + override=True + config.py 改函数式懒读取
|
||||||
|
- [CL-20260412-1500] 品牌重命名 Art Agent → EPEEKit + 后端 API/模型配置集中化到 .env + config.py
|
||||||
|
- [CL-20260412-0300] 完成交互原型全面重构:三栏布局 + 多会话标签系统 + Canvas 标注 + 资源库页面
|
||||||
- [CL-20260411-1630] 安装 Node.js v24.14.1 + Python venv + 前后端依赖,后端启动验证通过
|
- [CL-20260411-1630] 安装 Node.js v24.14.1 + Python venv + 前后端依赖,后端启动验证通过
|
||||||
- [CL-20260411-1600] 从零创建美术 Agent MVP 全部前后端代码(FastAPI + Next.js + Agent Loop + SSE)
|
- [CL-20260411-1600] 从零创建美术 Agent MVP 全部前后端代码(FastAPI + Next.js + Agent Loop + SSE)
|
||||||
- [CL-20260411-1500] 完成美术 Agent 产品决策(5 个核心问题)和技术选型(8 个维度)两份文档
|
- [CL-20260411-1500] 完成美术 Agent 产品决策(5 个核心问题)和技术选型(8 个维度)两份文档
|
||||||
|
|||||||
@@ -3,17 +3,108 @@
|
|||||||
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
最近 ~10 次改动的摘要记录,按时间倒序排列。
|
||||||
当 Agent 检测到当前任务与近期改动相关时自动读取。
|
当 Agent 检测到当前任务与近期改动相关时自动读取。
|
||||||
|
|
||||||
### [CL-20260411-1630] 2026-04-11 — 完成开发环境搭建和依赖安装
|
### [CL-20260413-0800] 2026-04-13 — 修复 InstantStyle ReadTimeout + 多次生成丢失参考图
|
||||||
- **tags**: art-agent, 环境搭建, Node.js, Python
|
- **tags**: bug修复, Replicate, InstantStyle, timeout, 参考图, Agent Loop, wait
|
||||||
- **affected_files**: art-agent/backend/venv/, art-agent/frontend/node_modules/
|
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py
|
||||||
- **summary**: 安装 Node.js v24.14.1(winget)、创建 Python venv 并安装后端依赖、安装前端 npm 依赖。后端启动验证通过。遇到并解决了 OpenAI 延迟初始化、PowerShell && 不支持、脚本执行策略限制等问题。
|
- **summary**: 运行时日志证实两个问题:(1) `wait=60` 时 SDK 内部 `read=60.5s` timeout 覆盖客户端 300s timeout,上传 1.6MB base64 + 等待初始响应超过 60s → ReadTimeout。改为 `wait=False`,create 请求立即返回 prediction ID,SDK 自动走 `prediction.async_wait()` 轮询,轮询用客户端级 read=300s timeout。(2) 成功生成后清除 ref_image_url 导致后续工具调用丢失参考图(InstantStyle 返回 "No input, Save money")。完全移除清除逻辑,ref_image_url 在整个对话期间保持有效。
|
||||||
|
|
||||||
### [CL-20260411-1600] 2026-04-11 — 搭建美术 Agent MVP 全部前后端代码
|
### [CL-20260413-0730] 2026-04-13 — 修复生图失败后 LLM 重试丢失参考图
|
||||||
- **tags**: art-agent, MVP, 前端, 后端, FastAPI, Next.js, Agent Loop
|
- **tags**: bug修复, Agent Loop, 参考图, InstantStyle, ref_image_url
|
||||||
- **affected_files**: art-agent/backend/app/*, , art-agent/frontend/src/*, docs/art-agent/MVP-PLAN.md
|
- **affected_files**: art-agent/backend/app/agent/loop.py
|
||||||
- **summary**: 从零创建完整 MVP:后端 FastAPI(Agent Loop + Replicate 图像生成 + SSE)+ 前端 Next.js(Chat UI + 参考图上传 + 图片下载 + 暗色主题)。范围刻意最小化:无数据库、无 Skill 机制、无用户认证。Agent Loop 硬编码 system prompt,使用 GPT-4o-mini + flux-schnell。
|
- **summary**: LLM 第一次生图工具调用失败后重试时,参考图丢失。InstantStyle 返回 "No input, Save money"。根因:`loop.py` 在每轮工具调用结束后无条件清除 `ref_image_url`。修复:改为只在本轮至少有一次成功产出图片时才清除,失败时保留参考图供 LLM 重试。
|
||||||
|
|
||||||
|
### [CL-20260413-0700] 2026-04-13 — 修复 InstantStyle 生图 422 + ReadTimeout:wait 参数超限
|
||||||
|
- **tags**: bug修复, Replicate, InstantStyle, API参数, async_run, timeout
|
||||||
|
- **affected_files**: art-agent/backend/app/services/image_gen.py
|
||||||
|
- **summary**: InstantStyle 生图 422(`Prefer: wait=x must be 1-60`)+ ReadTimeout(61.4s)。两个问题同根因:`async_run(wait=300)` 超出 API 限制,改为 `wait=True` 后 SDK 内部 60.5s timeout 覆盖了客户端 300s timeout。最终改为 `wait=60`(API 最大合法值),初始请求 60s 内未完成则 SDK 自动 fallback 到异步轮询,轮询用客户端级 read=300s timeout。
|
||||||
|
|
||||||
|
### [CL-20260413-0630] 2026-04-13 — 修复 Replicate SDK 不走代理 + 超时不够
|
||||||
|
- **tags**: bug修复, Replicate, InstantStyle, 代理, httpx, 超时
|
||||||
|
- **affected_files**: art-agent/backend/app/services/image_gen.py
|
||||||
|
- **summary**: Replicate SDK 内部显式传 `transport` 给 httpx,绕过了环境变量代理配置导致 ConnectTimeout,且默认 read 30s 不够 InstantStyle 的 ~128s。修复:`_make_replicate_client()` 工厂函数从 `HTTPS_PROXY` 读取代理注入 `AsyncHTTPTransport(proxy=...)`,read 300s / connect 30s。`_download_image` 同步加了代理。
|
||||||
|
|
||||||
|
### [CL-20260413-0600] 2026-04-13 — 新增 InstantStyle 模型:强风格迁移选项
|
||||||
|
- **tags**: 模型注册, InstantStyle, 风格迁移, IP-Adapter
|
||||||
|
- **affected_files**: art-agent/backend/app/config.py
|
||||||
|
- **summary**: 注册 InstantStyle(`jyoung105/instant-style`)到模型注册表,`block_mode: "style-only"` + `style_strength: 1.0`,通过 `ref_image_param: "style_image"` 适配现有抽象。与 Kolors 并列为参考图模型,提供强风格迁移能力。
|
||||||
|
|
||||||
|
### [CL-20260413-0540] 2026-04-13 — 非 vision LLM + 参考图时禁止猜测风格
|
||||||
|
- **tags**: prompt 工程, IP-Adapter, 风格一致性, system prompt
|
||||||
|
- **affected_files**: art-agent/backend/app/agent/loop.py
|
||||||
|
- **summary**: 修改 system prompt 和非 vision 模型的参考图提示,明确禁止猜测画风。LLM 只描述画面内容,风格交给 IP-Adapter 从参考图提取。解决了 DeepSeek 看不到参考图时脑补 "pixel art" 导致生成结果与参考图风格不一致的问题。
|
||||||
|
|
||||||
|
### [CL-20260413-0520] 2026-04-13 — 多图生成改为单次 API 调用 + 修复空错误信息
|
||||||
|
- **tags**: 性能优化, Replicate, 速率限制, 错误处理, 模型配置
|
||||||
|
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/config.py
|
||||||
|
- **summary**: 多图生成从 for 循环改为单次 API 调用(新增 `num_images_param` 配置),避免低余额账户的速率限制。同时修复空异常时 `[生成失败: ]` 信息丢失的问题,改为显示异常类型名和 repr。
|
||||||
|
|
||||||
|
### [CL-20260413-0500] 2026-04-13 — 生成图片后显示使用的模型名称
|
||||||
|
- **tags**: 模型溯源, SSE, 图片生成, 前端展示
|
||||||
|
- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/tools.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx
|
||||||
|
- **summary**: `generate_images` 返回 `GenerateResult`(含 model_name),SSE 事件携带模型名称,前端在图片下方和错误提示中展示。ChatMessage 新增 `modelName` 字段,归档后仍可溯源。
|
||||||
|
|
||||||
|
### [CL-20260413-0440] 2026-04-13 — 修复 Kolors IP-Adapter 模型 404 错误:补全 Replicate 版本 hash
|
||||||
|
- **tags**: bug修复, Replicate, Kolors, IP-Adapter, 模型配置
|
||||||
|
- **affected_files**: art-agent/backend/app/config.py
|
||||||
|
- **summary**: Kolors IP-Adapter 生图 404 的根因是 Replicate SDK 对社区模型需要 `owner/model:version_hash` 格式。在 config.py 中补全 kolors-ipadapter 的版本 hash 后,实测生图成功。
|
||||||
|
|
||||||
|
### [CL-20260413-0430] 2026-04-13 — 图片生成失败时将具体错误信息透传到前端
|
||||||
|
- **tags**: 错误处理, Agent Loop, SSE, 调试体验
|
||||||
|
- **affected_files**: art-agent/backend/app/agent/loop.py, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/api.ts
|
||||||
|
- **summary**: 新增 `tool_error` SSE 事件类型。Agent Loop 在工具执行产生错误时,额外 yield 一个 tool_error 事件携带具体报错信息;前端处理该事件并以 ⚠️ 前缀展示在对话流中。解决了之前工具失败时用户只能看到 LLM 模糊翻译、无法获取真实错误详情的问题。
|
||||||
|
|
||||||
|
### [CL-20260413-0330] 2026-04-13 — README 补充访问地址汇总 + 穿透管理文档
|
||||||
|
- **tags**: 文档, README, 穿透, Cloudflare, cloudflared
|
||||||
|
- **affected_files**: art-agent/README.md
|
||||||
|
- **summary**: README 新增"访问地址"章节(本机/局域网/外网三种场景表格),重写"远程访问(外网穿透)"章节,补充 cloudflared 安装指引(winget + 手动下载)、穿透管理操作表(启动/停止/查看状态/重启)、代理配置排错说明。
|
||||||
|
|
||||||
|
### [CL-20260412-2345] 2026-04-12 — 参考图上传独立化:进度条 + 状态反馈 + 失败重试
|
||||||
|
- **tags**: 前端, 后端, 参考图, 上传, UX, 进度反馈
|
||||||
|
- **affected_files**: art-agent/backend/app/api/chat.py, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx
|
||||||
|
- **summary**: 参考图从"随消息提交"改为"选中即独立上传"。后端新增 POST /api/upload-ref-image 端点;前端用 XMLHttpRequest 获取上传进度事件,ChatInput 显示环形进度条、成功/失败角标、失败可重选。sendChat 参数从 File 改为服务端路径。同时修复 file input 多次上传不重置的问题。
|
||||||
|
|
||||||
|
### [CL-20260412-2315] 2026-04-12 — 修复 Cloudflare Tunnel 跨域开发警告
|
||||||
|
- **tags**: 前端, 配置, Next.js, Cloudflare, 跨域
|
||||||
|
- **affected_files**: art-agent/frontend/next.config.ts
|
||||||
|
- **summary**: Next.js 检测到来自 trycloudflare.com 的跨域请求发出警告。在 next.config.ts 中添加 `allowedDevOrigins: ["*.trycloudflare.com"]` 消除警告,通配符覆盖所有 Quick Tunnel 随机域名。
|
||||||
|
|
||||||
|
### [CL-20260412-2300] 2026-04-12 — 集成 Kolors IP-Adapter,参考图可直接用于风格生成
|
||||||
|
- **tags**: 后端, 前端, IP-Adapter, 风格迁移, 参考图, 模型注册, Replicate, 架构
|
||||||
|
- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/components/chat/model-selector.tsx
|
||||||
|
- **summary**: 集成 Kolors IP-Adapter 模型(fofr/kolors-with-ipadapter,~$0.004/次),参考图通过 base64 data URI 直接传给 Replicate 的 IP-Adapter 参与风格生成。模型注册表新增 supports_ref_image / ref_image_param 通用字段,新增 IP-Adapter 模型只需加配置。to_data_uri 抽为公共函数供 loop.py 和 image_gen.py 共用。前端模型列表显示绿色"参考图"徽章。
|
||||||
|
|
||||||
|
### [CL-20260412-2230] 2026-04-12 — 用户消息气泡中显示参考图缩略图
|
||||||
|
- **tags**: 前端, 参考图, UX, chat-messages
|
||||||
|
- **affected_files**: art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx
|
||||||
|
- **summary**: 用户上传参考图后发送消息,之前聊天气泡中不显示参考图,无法确认是否成功附带。修复:ChatMessage 类型新增 refImageUrl 字段,page.tsx 构造用户消息时用 URL.createObjectURL 保存预览 URL,chat-messages.tsx 在用户消息气泡中渲染缩略图(最大 160×120px)。Blob URL 仅当前会话有效,不持久化。
|
||||||
|
|
||||||
|
### [CL-20260412-2200] 2026-04-12 — 修复 DeepSeek 不支持 image_url 导致参考图上传报错
|
||||||
|
- **tags**: bug修复, 后端, Agent Loop, vision, DeepSeek, 多模态
|
||||||
|
- **affected_files**: art-agent/backend/app/agent/loop.py
|
||||||
|
- **summary**: 上传参考图时使用 OpenAI Vision 格式(`image_url` content type)发送给 DeepSeek API,但 DeepSeek 只支持 `text` 类型,导致 400 错误。修复:新增 vision 能力检测(基于模型名称关键词),不支持 vision 的模型改为文字提示方式告知 LLM 有参考图,参考图仍传给图像生成工具处理。
|
||||||
|
|
||||||
|
### [CL-20260412-2130] 2026-04-12 — 修复图片生成失败时前端显示破碎图标
|
||||||
|
- **tags**: bug修复, 前端, 后端, 图像生成, 错误处理
|
||||||
|
- **affected_files**: art-agent/backend/app/agent/tools.py, art-agent/frontend/src/app/page.tsx
|
||||||
|
- **summary**: Replicate API 调用失败(如余额不足 402)时,错误字符串 `[生成失败: ...]` 被混入 images 列表传给前端,导致 `<img>` 标签渲染破碎图标。修复:后端 tools.py 分离有效 URL 和错误信息为 images/errors 两个字段;前端 page.tsx 过滤掉以 `[` 开头的无效 URL,无有效图片时不渲染图片网格。
|
||||||
|
|
||||||
|
### [CL-20260412-2100] 2026-04-12 — 生图模型动态切换:Provider 抽象 + 前端模型选择器
|
||||||
|
- **tags**: 后端, 前端, 图像生成, 模型切换, Provider, 重构, Replicate
|
||||||
|
- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/api/chat.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/agent/tools.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/model-selector.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx
|
||||||
|
- **summary**: 将图像生成从 Replicate 硬绑定重构为 Provider 抽象层。后端:config.py 新增 IMAGE_MODELS 注册表(flux-schnell / flux-dev / SDXL),image_gen.py 引入 ImageProvider 基类 + ReplicateProvider 实现,chat.py 新增 GET /api/models + POST /api/chat 增加 image_model 参数,agent loop / tools 全链路透传。前端:新建 ModelSelector 下拉组件集成到 ChatInput,每条消息可选不同模型,选择持久化到 localStorage。
|
||||||
|
|
||||||
|
### [CL-20260412-1800] 2026-04-12 — 移动端适配 + Cloudflare Tunnel 内网穿透
|
||||||
|
- **tags**: 前端, 响应式, 移动端, 部署, Cloudflare, 穿透, 配置
|
||||||
|
- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/app/globals.css, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/sidebar/sidebar.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/src/lib/app-context.tsx, art-agent/frontend/next.config.ts, art-agent/start-tunnel.ps1, art-agent/README.md
|
||||||
|
- **summary**: 为实现"手机浏览器远程访问"做了两大块工作。(1) 移动端响应式适配:侧边栏改为固定定位抽屉式(md 以下),顶栏新增汉堡菜单,图片详情面板移动端全屏 overlay,气泡/输入框/Gallery 网格间距微调,hover 操作在移动端始终可见,默认小屏折叠侧边栏。(2) 穿透方案:安装 cloudflared,编写 start-tunnel.ps1 一键脚本自动创建双隧道 + 更新 .env.local + 退出时恢复。Next.js 图片白名单加 trycloudflare.com。uvicorn 改绑 0.0.0.0。路线 B(云服务器部署)记为延期方案。
|
||||||
|
|
||||||
|
### [CL-20260412-1530] 2026-04-12 — 修复 .env 配置加载时序问题
|
||||||
|
- **tags**: 后端, bug修复, 配置, dotenv, Python
|
||||||
|
- **affected_files**: art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env
|
||||||
|
- **summary**: 切换 DeepSeek 后报 "Model Not Exist"。根因:(1) load_dotenv() 在业务模块 import 之后执行,config.py 的模块级变量在 import 时就固化为默认值 gpt-4o-mini;(2) load_dotenv 默认 override=False 不覆盖已有环境变量。修复:load_dotenv(override=True) 提到所有 import 之前;config.py 常量改为函数式懒读取;.env base_url 改为不带 /v1。
|
||||||
|
|
||||||
|
### [CL-20260412-1500] 2026-04-12 — 品牌重命名 EPEEKit + API 配置集中化
|
||||||
|
- **tags**: EPEEKit, 品牌, 配置, 后端, 前端, 重构
|
||||||
|
- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/store.ts, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/package.json, art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/README.md
|
||||||
|
- **summary**: 应用正式命名为 "EPEEKit",全部用户可见文案 + 文件名前缀 + localStorage 键名统一替换。后端新增 config.py 集中管理 LLM_MODEL、IMAGE_MODEL 等原硬编码配置,.env 重组为带注释的分组结构,切换 LLM 只需改 3 个环境变量。同时新增 .env.example 模板和 Logo SVG。
|
||||||
|
|
||||||
### [CL-20260411-1500] 2026-04-11 — 完成美术 Agent 产品决策和技术选型
|
|
||||||
- **tags**: art-agent, 产品决策, 技术选型, 规划
|
|
||||||
- **affected_files**: docs/art-agent/DECISIONS.md, docs/art-agent/TECH-STACK.md
|
|
||||||
- **summary**: 5 轮结构化讨论确定产品决策(Chat-first Web App、对话驱动交互、三层风格定义等)。8 维度技术选型:Next.js / FastAPI / 自建 Agent Loop / PostgreSQL / Replicate / Vercel+Railway。DECISIONS.md 和 TECH-STACK.md 两份文档产出。
|
|
||||||
|
|||||||
@@ -2,7 +2,94 @@
|
|||||||
|
|
||||||
## Active Items
|
## Active Items
|
||||||
|
|
||||||
(暂无延期方案)
|
### [cloud-deploy-route-b] 路线 B:云服务器正式部署
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: deployment, docker, nginx, cloud, production
|
||||||
|
- **recorded**: 2026-04-12
|
||||||
|
- **source_chat**: [发布到手机浏览器的方案讨论](discussion-publish-routes)
|
||||||
|
- **prerequisite**: 路线 A(内网穿透 + 移动端适配)完成后
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/backend/app/main.py
|
||||||
|
- art-agent/frontend/next.config.ts
|
||||||
|
- art-agent/frontend/.env.local
|
||||||
|
- art-agent/backend/.env
|
||||||
|
- **context**: |
|
||||||
|
买轻量云服务器(2C2G ~50元/月),编写前后端 Dockerfile + docker-compose,
|
||||||
|
配置 Nginx/Caddy 反向代理统一入口,HTTPS 自动签证书,
|
||||||
|
环境变量搬迁(去掉本地代理配置),可选绑定域名。
|
||||||
|
预估工作量 1-2 天。
|
||||||
|
- **chosen_alternative**: 路线 A — 本地运行 + 内网穿透(Cloudflare Tunnel / ngrok)
|
||||||
|
- **deferred_reason**: 当前阶段只需自己和朋友试用,内网穿透足够
|
||||||
|
|
||||||
|
### [style-pipeline-layered] 分层组合风格一致性产线
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: 风格一致性, IP-Adapter, LoRA, ControlNet, 量产, 美术产线, InstantStyle
|
||||||
|
- **recorded**: 2026-04-12
|
||||||
|
- **updated**: 2026-04-13
|
||||||
|
- **source_chat**: [风格一致性技术方案讨论](style-consistency-discussion)
|
||||||
|
- **prerequisite**: EPEEKit 进入美术资源量产阶段(风格方向已确定、需要批量产出同风格素材)
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- **context**: |
|
||||||
|
分层组合的风格一致性产线:
|
||||||
|
1. 探索期(当前):Prompt + 参考图,快速试错确定风格方向
|
||||||
|
2. 锁定期:IP-Adapter / ControlNet,选 3-5 张精选图作为风格锚点,推理时注入风格
|
||||||
|
3. 量产期:LoRA + ControlNet,用精选图训练 LoRA(20-50 张),批量产出同风格资源
|
||||||
|
4. 贯穿全程:标准化 Prompt 模板 + Negative Prompt 模板,确保品质下限
|
||||||
|
各阶段可叠加使用,不互斥。LoRA 训练需要同风格样本积累到足够数量。
|
||||||
|
|
||||||
|
2026-04-13 调研补充 — 风格迁移模型层级:
|
||||||
|
- Kolors IP-Adapter(当前在用):通用 IP-Adapter,风格迁移弱,内容+风格+构图混合提取
|
||||||
|
- InstantStyle(Replicate 可用):专门分离内容/风格,风格迁移强,但贵且慢
|
||||||
|
- Style IPAdapter for NoobAI-XL(CivitAI):最强画风迁移(线条+着色技法),需 ComfyUI
|
||||||
|
- ICAS 框架(学术前沿 2025.04):IP-Adapter + ControlNet 组合,多主体风格一致性最优
|
||||||
|
锁定期的最佳方案:迁移到 HF Inference Endpoints 后,用 NoobAI-XL + Style IPAdapter + ControlNet
|
||||||
|
- **chosen_alternative**: 当前使用 Prompt Engineering + LLM 参考图理解(探索期方案)
|
||||||
|
- **deferred_reason**: 风格方向尚未确定,过早引入 LoRA/ControlNet 是过度优化
|
||||||
|
|
||||||
|
### [hf-inference-endpoints] 迁移到 Hugging Face + Inference Endpoints
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: 基础设施, Hugging Face, Inference Endpoints, ComfyUI, 自定义模型, 风格迁移
|
||||||
|
- **recorded**: 2026-04-13
|
||||||
|
- **source_chat**: [Kolors 风格迁移排查与模型调研](kolors-style-investigation)
|
||||||
|
- **prerequisite**: 探索期结束、确定要用的模型组合后
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- art-agent/backend/app/config.py
|
||||||
|
- **context**: |
|
||||||
|
将生图基础设施从 Replicate API 迁移到 Hugging Face Inference Endpoints:
|
||||||
|
1. 可部署任意 ComfyUI 工作流(NoobAI-XL + Style IPAdapter + ControlNet 等任意组合)
|
||||||
|
2. 不受 Replicate 模型作者暴露的参数限制,完全控制推理流程
|
||||||
|
3. CivitAI 上的任何 LoRA、IP-Adapter 权重都能直接加载
|
||||||
|
4. 按 GPU 时长计费,批量生成时远比 Replicate 按次付费便宜
|
||||||
|
5. 是 style-pipeline-layered 延期方案中"锁定期 → 量产期"的基础设施
|
||||||
|
背景:当前在 Replicate 上可用的风格迁移模型有限——
|
||||||
|
- Kolors IP-Adapter:风格迁移弱(混合提取内容+风格+构图)
|
||||||
|
- InstantStyle(jyoung105/instant-style):风格迁移强但贵($0.12/次)且慢(~128s)
|
||||||
|
- Style IPAdapter for NoobAI-XL:最强画风迁移,但需 ComfyUI 环境,Replicate 上无法使用
|
||||||
|
迁移到 HF Inference Endpoints 后可自由组合以上所有方案。
|
||||||
|
- **chosen_alternative**: 当前使用 Replicate API(Kolors IP-Adapter + Flux + SDXL)
|
||||||
|
- **deferred_reason**: 探索期用 Replicate 足够快速迭代,迁移工作量较大(需搭建自定义推理服务)
|
||||||
|
|
||||||
|
### [vision-model-asset-library] 专用 Vision 模型用于资源库功能
|
||||||
|
- **status**: deferred
|
||||||
|
- **tags**: 资源库, vision, CLIP, BLIP, 图片搜索, 自动标签, embedding
|
||||||
|
- **recorded**: 2026-04-12
|
||||||
|
- **source_chat**: [参考图风格解析方案讨论](vision-model-discussion)
|
||||||
|
- **prerequisite**: 资源库功能进入增强阶段(基础 Gallery 已完成)
|
||||||
|
- **related_files**:
|
||||||
|
- art-agent/frontend/src/app/gallery/page.tsx
|
||||||
|
- art-agent/backend/app/services/image_gen.py
|
||||||
|
- **context**: |
|
||||||
|
引入专用 Vision 模型(CLIP / BLIP 等)为资源库提供智能化能力:
|
||||||
|
1. 自动标签:生成的图片自动分类(UI/角色/场景/图标等)
|
||||||
|
2. 以图搜图:上传一张图,从资源库中找风格最相似的
|
||||||
|
3. 语义搜索:用自然语言搜索图片(如"蓝色水晶质感的按钮")
|
||||||
|
4. 聚类分组:自动将图片按视觉相似性分组
|
||||||
|
可通过 Replicate 调用或本地部署,成本极低。
|
||||||
|
- **chosen_alternative**: 当前资源库使用手动标签 + Prompt 文本搜索
|
||||||
|
- **deferred_reason**: 当前阶段资源量少,手动管理足够;等资源积累到一定量级后再引入自动化
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
23
.cursor/hooks.json
Normal file
23
.cursor/hooks.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"hooks": {
|
||||||
|
"sessionStart": [
|
||||||
|
{
|
||||||
|
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/session-init.ps1",
|
||||||
|
"timeout": 5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stop": [
|
||||||
|
{
|
||||||
|
"type": "prompt",
|
||||||
|
"prompt": "You are a changelog compliance checker. Return ONLY valid JSON, nothing else.\n\nSTEP 1 — EXEMPTION CHECK (if ANY condition is true, return {} immediately):\n- The conversation is in Debug mode, debug-mode, or any debugging/troubleshooting context\n- The agent's recent actions were ONLY: reading files, running shell commands, adding log/debug statements, or modifying .cursor/ files\n- The agent did NOT use Write or StrReplace tools on files outside .cursor/ directory\n- The agent was acknowledging a previous hook reminder\n- There is a CURSOR_SKIP_CHANGELOG environment variable set\n\nIf ANY exemption matches → return: {}\n\nSTEP 2 — Only if NO exemption matched:\nCheck if the agent used Write or StrReplace on source files (*.py, *.tsx, *.ts, *.css, *.json outside .cursor/) AND did NOT also write to all three: .cursor/changelog/changelog-full.md, changelog-recent.md, changelog-headlines.md.\n\nIf changelog writes were missed → return: {\"followup_message\":\"Changelog not synced. Run dev-changelog Skill operation A to write all three changelog layers.\"}\n\nOtherwise → return: {}\n\nHere is the hook input: $ARGUMENTS",
|
||||||
|
"timeout": 15,
|
||||||
|
"loop_limit": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "powershell -ExecutionPolicy Bypass -File .cursor/hooks/check-changelog.ps1",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
72
.cursor/hooks/check-changelog.ps1
Normal file
72
.cursor/hooks/check-changelog.ps1
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Changelog sync guard: compare source file mtime vs changelog mtime.
|
||||||
|
# If source files are newer than changelog, inject a followup_message
|
||||||
|
# reminding the agent to write changelog entries.
|
||||||
|
#
|
||||||
|
# 静默条件(不触发提醒):
|
||||||
|
# 1. 环境变量 CURSOR_SKIP_CHANGELOG 被设置(由 sessionStart hook 在非 agent 模式设置)
|
||||||
|
# 2. Debug 模式 — 项目根目录存在 debug-*.log 文件,说明正在调试
|
||||||
|
# 3. changelog 文件不存在
|
||||||
|
# 4. .changelog-ack 标记文件存在且足够新(本会话已确认过 changelog 状态)
|
||||||
|
|
||||||
|
$input = [Console]::In.ReadToEnd()
|
||||||
|
|
||||||
|
# 检测 sessionStart 设置的跳过标志(覆盖一开始就是 debug/ask 模式的场景)
|
||||||
|
if ($env:CURSOR_SKIP_CHANGELOG) {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$changelog = ".cursor\changelog\changelog-headlines.md"
|
||||||
|
$srcDir = "art-agent"
|
||||||
|
|
||||||
|
if (-not (Test-Path $changelog)) {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检测 debug 模式:项目中存在活跃的 debug 日志文件
|
||||||
|
$debugLogs = Get-ChildItem -Path "." -Filter "debug-*.log" -Recurse -Depth 3 -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.FullName -notlike "*node_modules*" -and $_.FullName -notlike "*venv*" }
|
||||||
|
if ($debugLogs) {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$clMtime = (Get-Item $changelog).LastWriteTime
|
||||||
|
|
||||||
|
# 检测 ack 标记文件:Agent 已确认 changelog 状态(写入或跳过),本会话不再提醒
|
||||||
|
$ackFile = ".cursor\changelog\.changelog-ack"
|
||||||
|
if ((Test-Path $ackFile) -and (Get-Item $ackFile).LastWriteTime -ge $clMtime) {
|
||||||
|
Write-Output '{}'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$extensions = @("*.py", "*.tsx", "*.ts", "*.css")
|
||||||
|
$excludeDirs = @("node_modules", ".next", "__pycache__", "venv")
|
||||||
|
|
||||||
|
$newerFile = $null
|
||||||
|
foreach ($ext in $extensions) {
|
||||||
|
$files = Get-ChildItem -Path $srcDir -Filter $ext -Recurse -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object {
|
||||||
|
$skip = $false
|
||||||
|
foreach ($ex in $excludeDirs) {
|
||||||
|
if ($_.FullName -like "*\$ex\*") { $skip = $true; break }
|
||||||
|
}
|
||||||
|
-not $skip -and $_.LastWriteTime -gt $clMtime
|
||||||
|
} |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if ($files) {
|
||||||
|
$newerFile = $files.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newerFile) {
|
||||||
|
$msg = "[Hook] Source file updated (e.g. $newerFile) but changelog not synced. Run dev-changelog Skill operation A NOW to write all three changelog layers."
|
||||||
|
$json = '{"followup_message":"' + $msg.Replace('"','\"') + '"}'
|
||||||
|
Write-Output $json
|
||||||
|
} else {
|
||||||
|
Write-Output '{}'
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
23
.cursor/hooks/session-init.ps1
Normal file
23
.cursor/hooks/session-init.ps1
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# session-init.ps1 — 会话启动时检测 composer_mode,非 agent 模式设置跳过标志
|
||||||
|
#
|
||||||
|
# sessionStart input 包含 composer_mode 字段("agent" / "ask" / "edit" / "debug" 等)
|
||||||
|
# 通过 env 输出的环境变量会传递给同会话内所有后续 hook
|
||||||
|
|
||||||
|
$input = [Console]::In.ReadToEnd()
|
||||||
|
|
||||||
|
try {
|
||||||
|
$data = $input | ConvertFrom-Json
|
||||||
|
$mode = $data.composer_mode
|
||||||
|
} catch {
|
||||||
|
$mode = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
Remove-Item ".cursor\changelog\.changelog-ack" -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($mode -and $mode -ne "agent") {
|
||||||
|
$json = '{"env":{"CURSOR_SKIP_CHANGELOG":"1","CURSOR_COMPOSER_MODE":"' + $mode + '"}}'
|
||||||
|
Write-Output $json
|
||||||
|
} else {
|
||||||
|
Write-Output '{}'
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
13
.cursor/pitfalls/pitfalls.md
Normal file
13
.cursor/pitfalls/pitfalls.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Pitfall Journal
|
||||||
|
|
||||||
|
开发过程中踩过的坑,按时间倒序排列。
|
||||||
|
Agent 进入 Debug mode 或遇到运行时错误时自动检索匹配。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [PF-20260412-1530] Python load_dotenv 默认不覆盖已有环境变量 + 模块级变量在 import 时固化
|
||||||
|
- **症状**: 修改 .env 切换 API 供应商后,后端仍报旧供应商的错误(如 DeepSeek 报 "Model Not Exist",实际发送的模型名是 OpenAI 的 gpt-4o-mini)
|
||||||
|
- **根因**: 两层叠加:(1) `load_dotenv()` 默认 `override=False`,如果进程/系统中已有同名环境变量,.env 中的新值不会生效;(2) `config.py` 使用模块级常量 `LLM_MODEL = os.getenv(...)` 在 import 时就求值固化,而 `main.py` 的 import 链在 `load_dotenv()` 之前就触发了 config.py 的加载
|
||||||
|
- **解法**: (1) `load_dotenv(override=True)` 并提到所有业务 import 之前;(2) config.py 改为函数式 `get_llm_model()`,运行时才读取
|
||||||
|
- **防御**: 任何 Python 项目使用 dotenv 时,始终 `override=True` + 放在文件最顶部(仅在 `import os` 和 `from dotenv import load_dotenv` 之后);配置值用函数或属性包装,不要用模块级常量
|
||||||
|
- **关联**: Python, dotenv, FastAPI, uvicorn, 环境变量, 配置管理, OpenAI SDK
|
||||||
@@ -12,6 +12,32 @@ alwaysApply: true
|
|||||||
3. 这些信息帮助 Agent 快速建立位置感:项目进展到什么阶段、最近的工作重心在哪个模块
|
3. 这些信息帮助 Agent 快速建立位置感:项目进展到什么阶段、最近的工作重心在哪个模块
|
||||||
4. 在后续回复中自然参考,不显式提及"根据开发日志"
|
4. 在后续回复中自然参考,不显式提及"根据开发日志"
|
||||||
|
|
||||||
|
## 主动定位辅助
|
||||||
|
|
||||||
|
L3 概要的核心价值之一是帮助 Agent 在**冷启动**(新会话、无上下文)时理解用户意图。
|
||||||
|
当用户的请求缺少具体文件名或模块名时,Agent 应主动利用 L3 进行推断:
|
||||||
|
|
||||||
|
### 典型场景
|
||||||
|
|
||||||
|
1. **隐式延续**:用户说"继续做昨天那个"、"把那个功能完善一下"
|
||||||
|
→ 从 L3 中找到最近的相关条目,上溯到 L2 获取具体文件列表
|
||||||
|
2. **模糊指代**:用户说"那个组件有 bug"、"之前改的那个接口"
|
||||||
|
→ 用 L3 中的关键词匹配用户描述,定位到具体改动
|
||||||
|
3. **上下文补全**:用户直接提出一个任务,没有背景说明
|
||||||
|
→ 用 L3 判断该任务是否与近期某个改动有关联(如同一模块、同一功能线)
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 解析用户请求,识别是否存在隐式引用或模糊指代
|
||||||
|
2. 在 L3 概要中查找语义最匹配的 1-3 条记录
|
||||||
|
3. 提取匹配条目的锚点 ID,上溯到 L2 获取 affected_files 和 tags
|
||||||
|
4. 如有必要,继续上溯到 L1 获取完整的决策背景
|
||||||
|
5. 将定位到的文件/模块作为任务的起点,开始执行
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 L3 中没有匹配到任何相关记录,正常处理即可——不是所有任务都与近期改动有关。
|
||||||
|
|
||||||
## L2 自动触发
|
## L2 自动触发
|
||||||
|
|
||||||
Agent 开始处理一个新任务时,判断是否需要读取近期详细记录:
|
Agent 开始处理一个新任务时,判断是否需要读取近期详细记录:
|
||||||
@@ -42,7 +68,30 @@ Agent 开始处理一个新任务时,判断是否需要读取近期详细记
|
|||||||
获取行号后用 Read 工具读取该行号 ±20 行范围
|
获取行号后用 Read 工具读取该行号 ±20 行范围
|
||||||
4. 一次上溯通常只涉及 1-3 条记录,不批量上溯
|
4. 一次上溯通常只涉及 1-3 条记录,不批量上溯
|
||||||
|
|
||||||
## 被动写入提醒
|
## 任务完成 Checklist(强制)
|
||||||
|
|
||||||
Agent 在完成涉及代码/配置文件实质性改动的任务后,应读取 `dev-changelog` Skill
|
Agent 在即将输出最终回复前,**必须**逐项检查以下清单。
|
||||||
并执行其"操作 A:记录写入"流程,静默追加三层记录。
|
这是硬性要求,不是建议——**跳过任何一项都视为执行错误**。
|
||||||
|
|
||||||
|
1. **本次是否有代码/配置文件改动?**(不含 `.cursor/` 下的基础设施文件)
|
||||||
|
- 否 → 跳过后续项
|
||||||
|
- 是 → 继续
|
||||||
|
|
||||||
|
2. **是否已写入三层开发日志?**
|
||||||
|
- 否 → **立即**读取 `dev-changelog` Skill 并执行操作 A,写入 L1 + L2 + L3 后再输出回复
|
||||||
|
- 是 → 继续
|
||||||
|
|
||||||
|
3. **是否修改了任何 Skill?**(包括 SKILL.md 内容变更)
|
||||||
|
- 否 → 跳过
|
||||||
|
- 是 → 检查是否已同步 `epee-orchestrator` 的 `registry.md`,未同步则立即执行
|
||||||
|
|
||||||
|
4. **本次是否有 Skill 使用异常?**(遗漏、误触、产物与用户预期偏差)
|
||||||
|
- 否 → 跳过
|
||||||
|
- 是 → 执行 `epee-orchestrator.mdc` 中的自迭代确认流程
|
||||||
|
|
||||||
|
5. **静默 Hook 重复触发**(每次 checklist 完成后必须执行)
|
||||||
|
- 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack`
|
||||||
|
- 这会更新 ack 文件的 mtime,阻止 `check-changelog.ps1` hook 在后续回复中重复提醒
|
||||||
|
|
||||||
|
**执行原则**:先完成 checklist 中的所有写入动作,再输出面向用户的回复文本。
|
||||||
|
回复末尾如果执行了第 2 项,附 `[已记录到开发日志]`。
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ Agent **必须**执行以下操作:
|
|||||||
2. 更新或新增对应 Skill 的条目(格式参见 registry.md 中的条目结构)
|
2. 更新或新增对应 Skill 的条目(格式参见 registry.md 中的条目结构)
|
||||||
3. 确保条目中的能力描述和触发场景与 Skill 实际内容一致
|
3. 确保条目中的能力描述和触发场景与 Skill 实际内容一致
|
||||||
|
|
||||||
|
> **注意**:此项已纳入 `changelog-recall.mdc` 的"任务完成 Checklist"第 3 项。
|
||||||
|
> 如果 Agent 在 checklist 阶段发现遗漏,必须立即补执行。
|
||||||
|
|
||||||
### Skill 自迭代(强制)
|
### Skill 自迭代(强制)
|
||||||
|
|
||||||
每个 SKILL.md 必须包含一个"自迭代日志"章节,用于记录使用该 Skill 过程中发现的经验教训。
|
每个 SKILL.md 必须包含一个"自迭代日志"章节,用于记录使用该 Skill 过程中发现的经验教训。
|
||||||
@@ -38,6 +41,9 @@ Agent **必须**执行以下操作:
|
|||||||
2. 用户需要反复补充同类信息
|
2. 用户需要反复补充同类信息
|
||||||
3. 生成产物与用户预期存在系统性偏差
|
3. 生成产物与用户预期存在系统性偏差
|
||||||
|
|
||||||
|
> **注意**:此项已纳入 `changelog-recall.mdc` 的"任务完成 Checklist"第 4 项。
|
||||||
|
> Agent 不应等到"下次使用 Skill 时"才想起自迭代——当次就应检查。
|
||||||
|
|
||||||
**流程**:
|
**流程**:
|
||||||
|
|
||||||
1. 识别问题根因,归纳为一条简明的检查项
|
1. 识别问题根因,归纳为一条简明的检查项
|
||||||
|
|||||||
31
.cursor/rules/common/pitfall-recall.mdc
Normal file
31
.cursor/rules/common/pitfall-recall.mdc
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
## 踩坑经验自动检索
|
||||||
|
|
||||||
|
### 被动检测触发
|
||||||
|
|
||||||
|
Agent 在以下场景中,应自动读取 `.cursor/pitfalls/pitfalls.md` 并进行匹配检索:
|
||||||
|
|
||||||
|
1. **进入 Debug mode**:读取全部条目,将当前错误症状与已有记录比对
|
||||||
|
2. **遇到运行时错误**:提取错误信息关键词,在"症状"字段中检索匹配
|
||||||
|
3. **同一问题第二次出现**:如果当前会话中某个错误已出现过一次且未解决,强制检索
|
||||||
|
|
||||||
|
### 匹配策略
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 提取当前问题的信号:错误信息关键词、涉及文件/模块、技术栈
|
||||||
|
2. 在 pitfalls.md 中匹配:
|
||||||
|
- 硬匹配:错误关键词出现在条目的"症状"中
|
||||||
|
- 软匹配:模块/技术栈出现在条目的"关联"中
|
||||||
|
3. 命中时在分析开头提示:
|
||||||
|
> 注意:之前遇到过类似问题 [PF-xxx]:[标题]。根因是 [xxx],先排查这个方向。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 写入提醒
|
||||||
|
|
||||||
|
Agent 在完成涉及 debug/修复的任务后,应读取 `pitfall-journal` Skill 并执行其"操作 A:写入记录"流程。
|
||||||
|
判断标准:问题的根因是否"非显而易见"——如果只看代码逻辑觉得应该没问题,但实际运行时才暴露,就值得记录。
|
||||||
|
|
||||||
|
### 注意
|
||||||
|
|
||||||
|
- 检索结果是**辅助参考**,不是确定性答案——匹配到不代表根因一定相同
|
||||||
|
- pitfalls.md 不存在时跳过,不报错
|
||||||
|
- 每次会话中对同一条 pitfall 最多提醒一次
|
||||||
18
.cursor/rules/project/project-launcher.mdc
Normal file
18
.cursor/rules/project/project-launcher.mdc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
description: 当用户要求启动项目、运行项目、打开前端/后端服务时触发
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
## 项目启动快捷指令
|
||||||
|
|
||||||
|
当用户表达以下意图时,读取并执行 `project-launcher` Skill:
|
||||||
|
|
||||||
|
**触发关键词**:启动项目、运行项目、跑起来、打开前端和后端、启动服务、start、launch、run dev、启动前端、启动后端
|
||||||
|
|
||||||
|
**执行方式**:
|
||||||
|
|
||||||
|
1. 读取 `.cursor/skills/project-launcher/SKILL.md`
|
||||||
|
2. 按 Skill 中的流程,在**独立可见的终端窗口**中启动前端和/或后端
|
||||||
|
3. 必须使用 `Start-Process`(PowerShell)或 `start cmd`(CMD)打开新窗口
|
||||||
|
4. **不要**在 Cursor 内置终端中后台运行——用户需要看到窗口并能手动关闭
|
||||||
@@ -15,7 +15,7 @@ description: >-
|
|||||||
| 层级 | 文件 | 信息密度 | 条目数量 | 注入方式 |
|
| 层级 | 文件 | 信息密度 | 条目数量 | 注入方式 |
|
||||||
|------|------|---------|---------|---------|
|
|------|------|---------|---------|---------|
|
||||||
| L1 完整版 | `changelog-full.md` | 高(5-15 行/条) | 无上限,只追加 | 用户手动唤醒 |
|
| L1 完整版 | `changelog-full.md` | 高(5-15 行/条) | 无上限,只追加 | 用户手动唤醒 |
|
||||||
| L2 中期版 | `changelog-recent.md` | 中(3-5 行/条) | 滚动窗口 ~10 条 | 检测到关联时自动读取 |
|
| L2 中期版 | `changelog-recent.md` | 中(3-5 行/条) | 滚动窗口 ~20 条 | 检测到关联时自动读取 |
|
||||||
| L3 概要版 | `changelog-headlines.md` | 低(1 行/条) | 滚动窗口 ~50 条 | 每次会话自动注入 |
|
| L3 概要版 | `changelog-headlines.md` | 低(1 行/条) | 滚动窗口 ~50 条 | 每次会话自动注入 |
|
||||||
|
|
||||||
所有数据文件存放在 `.cursor/changelog/` 目录下。
|
所有数据文件存放在 `.cursor/changelog/` 目录下。
|
||||||
@@ -182,12 +182,12 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
|
|||||||
|
|
||||||
## 滚动窗口维护
|
## 滚动窗口维护
|
||||||
|
|
||||||
### L2 窗口(~10 条)
|
### L2 窗口(~20 条)
|
||||||
|
|
||||||
```
|
```
|
||||||
写入新条目后,检查总条目数:
|
写入新条目后,检查总条目数:
|
||||||
- <= 10 条:不做处理
|
- <= 20 条:不做处理
|
||||||
- > 10 条:移除文件底部(最旧的)条目,直到恰好 10 条
|
- > 20 条:移除文件底部(最旧的)条目,直到恰好 20 条
|
||||||
```
|
```
|
||||||
|
|
||||||
### L3 窗口(~50 条)
|
### L3 窗口(~50 条)
|
||||||
@@ -246,4 +246,4 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后
|
|||||||
|
|
||||||
### 已知必要检查
|
### 已知必要检查
|
||||||
|
|
||||||
(暂无)
|
1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时,Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。
|
||||||
|
|||||||
@@ -122,4 +122,4 @@ description: >-
|
|||||||
|
|
||||||
### 已知必要检查
|
### 已知必要检查
|
||||||
|
|
||||||
(暂无)
|
1. **收尾动作级联遗漏** — 当 Agent 遗漏了一个收尾动作(如 changelog 写入)后,后续的收尾动作(自迭代、Registry 同步)也会被一并遗漏,因为它们都在同一个"收尾阶段"。`changelog-recall.mdc` 中的 Checklist 化设计可以打断这种级联——每项独立检查,不依赖前一项的执行记忆。
|
||||||
|
|||||||
@@ -39,4 +39,20 @@
|
|||||||
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
|
- **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入
|
||||||
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
|
- **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md
|
||||||
- **路径**: .cursor/skills/dev-changelog/SKILL.md
|
- **路径**: .cursor/skills/dev-changelog/SKILL.md
|
||||||
- **备注**: 写入由 changelog-recall Rule 的被动写入提醒触发;L3 概要每次会话自动注入上下文
|
- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;stop Hook 双保险兜底
|
||||||
|
|
||||||
|
### pitfall-journal
|
||||||
|
- **类型**: 基础设施
|
||||||
|
- **能力**: 记录开发中踩过的坑(非显而易见的问题),在遇到同类问题时自动检索匹配已有经验
|
||||||
|
- **触发场景**: debug 完成后、"踩坑"、"之前遇到过"、"坑"、进入 Debug mode、同类错误反复出现
|
||||||
|
- **输出**: .cursor/pitfalls/pitfalls.md 条目
|
||||||
|
- **路径**: .cursor/skills/pitfall-journal/SKILL.md
|
||||||
|
- **备注**: 与 dev-changelog 互补——changelog 记事实,pitfall 记经验;由 pitfall-recall Rule 触发自动检索
|
||||||
|
|
||||||
|
### project-launcher
|
||||||
|
- **类型**: 项目级
|
||||||
|
- **能力**: 一键在独立可见终端窗口中启动 Art Agent 前端和后端服务
|
||||||
|
- **触发场景**: "启动项目"、"运行项目"、"跑起来"、"打开前端和后端"、"启动服务"、"start"、"launch"、"run dev"、"启动前端"、"启动后端"
|
||||||
|
- **输出**: 两个独立的终端窗口(前端 Next.js + 后端 FastAPI)
|
||||||
|
- **路径**: .cursor/skills/project-launcher/SKILL.md
|
||||||
|
- **备注**: 项目专属 Skill;窗口必须可见,用户可直接查看日志和手动关闭
|
||||||
|
|||||||
116
.cursor/skills/pitfall-journal/SKILL.md
Normal file
116
.cursor/skills/pitfall-journal/SKILL.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
name: pitfall-journal
|
||||||
|
description: >-
|
||||||
|
踩坑经验记录系统。在调试完成或发现非显而易见的坑后记录根因和解决方式,
|
||||||
|
后续遇到同类问题时自动检索匹配,避免重复踩坑。
|
||||||
|
当 debug 完成、问题反复出现、或用户提到"踩坑"、"之前遇到过"、"坑"时触发。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pitfall Journal
|
||||||
|
|
||||||
|
记录开发过程中遇到的"坑"——那些不看代码逻辑觉得应该没问题、但实际运行时才暴露的问题。
|
||||||
|
与 dev-changelog 互补:changelog 记"做了什么",pitfall-journal 记"踩了什么坑、怎么爬出来的"。
|
||||||
|
|
||||||
|
## 数据文件
|
||||||
|
|
||||||
|
所有记录存放在 `.cursor/pitfalls/pitfalls.md`。
|
||||||
|
|
||||||
|
## 条目格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [PF-YYYYMMDD-HHMM] 一句话标题
|
||||||
|
- **症状**: 用户/系统看到的错误表现
|
||||||
|
- **根因**: 技术层面的真正原因
|
||||||
|
- **解法**: 具体怎么修的
|
||||||
|
- **防御**: 以后如何避免(可选,如果有通用性的话)
|
||||||
|
- **关联**: 相关文件、模块、技术栈标签
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作 A:写入记录
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
以下任一场景触发:
|
||||||
|
|
||||||
|
1. **调试完成后** — 经历了 debug 过程并找到了非显而易见的根因
|
||||||
|
2. **用户主动提及** — "记录一下这个坑"、"以后别再犯"
|
||||||
|
3. **Agent 识别到经验价值** — 问题涉及框架/库的隐式行为、配置陷阱、环境差异等
|
||||||
|
|
||||||
|
以下情况**不触发**:
|
||||||
|
- 纯拼写错误、简单语法错误
|
||||||
|
- 问题原因一目了然(如变量名打错)
|
||||||
|
- 纯业务逻辑调整(不涉及"坑"的语义)
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 生成条目 ID:PF-YYYYMMDD-HHMM
|
||||||
|
2. 从调试过程中提取:症状、根因、解法
|
||||||
|
3. 归纳防御措施(如果有通用性)
|
||||||
|
4. 读取 pitfalls.md,在顶部追加新条目
|
||||||
|
5. 在回复末尾附:[已记录到踩坑日志]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 静默写入原则
|
||||||
|
|
||||||
|
与 dev-changelog 一致——Agent 自己调试出来的问题,不需要用户确认就可以记录。
|
||||||
|
|
||||||
|
## 操作 B:自动匹配检索
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
当 Agent 在当前任务中遇到以下情况时,应主动检索 pitfalls.md:
|
||||||
|
|
||||||
|
1. **进入 Debug mode** — 读取 pitfalls.md,扫描是否有与当前错误症状匹配的记录
|
||||||
|
2. **同类错误再现** — 错误信息关键词与已有条目的"症状"匹配
|
||||||
|
3. **涉及已知高危区域** — 当前操作涉及的模块/技术栈在已有条目的"关联"中出现
|
||||||
|
|
||||||
|
### 匹配策略
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 提取当前问题的关键信号:
|
||||||
|
- 错误信息关键词
|
||||||
|
- 涉及的文件/模块
|
||||||
|
- 涉及的技术栈/框架
|
||||||
|
|
||||||
|
2. 在 pitfalls.md 中匹配:
|
||||||
|
- 硬匹配:错误信息关键词出现在条目的"症状"中
|
||||||
|
- 软匹配:涉及的模块/技术栈出现在条目的"关联"中
|
||||||
|
|
||||||
|
3. 命中时,在分析中优先考虑已有经验:
|
||||||
|
> 注意:之前遇到过类似问题 [PF-xxx]:[一句话描述]。
|
||||||
|
> 上次的根因是 [xxx],先排查这个方向。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 操作 C:手动检索
|
||||||
|
|
||||||
|
### 触发条件
|
||||||
|
|
||||||
|
用户主动要求回顾踩坑记录。典型话语:
|
||||||
|
- "之前那个坑是什么来着"
|
||||||
|
- "看看踩坑日志"
|
||||||
|
- "有遇到过类似的问题吗"
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 读取 pitfalls.md
|
||||||
|
2. 根据用户描述匹配相关条目
|
||||||
|
3. 展示匹配结果
|
||||||
|
```
|
||||||
|
|
||||||
|
## 与其他系统的协作
|
||||||
|
|
||||||
|
| 系统 | 关系 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `dev-changelog` | 互补 | changelog 记改动事实,pitfall 记经验教训 |
|
||||||
|
| `pitfall-recall` Rule | 下游消费者 | 进入 Debug mode 或遇到错误时自动触发检索 |
|
||||||
|
| `epee-orchestrator` | 注册 | 在 registry.md 中注册本 Skill |
|
||||||
|
|
||||||
|
## 自迭代日志
|
||||||
|
|
||||||
|
本节记录使用本 Skill 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
(暂无)
|
||||||
131
.cursor/skills/project-launcher/SKILL.md
Normal file
131
.cursor/skills/project-launcher/SKILL.md
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
name: project-launcher
|
||||||
|
description: >-
|
||||||
|
Art Agent 项目一键启动。当用户说"启动项目"、"运行项目"、"打开前端和后端"等时触发,
|
||||||
|
自动在独立的可见终端窗口中启动前端(Next.js)和后端(FastAPI),方便用户随时查看和关闭。
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Launcher
|
||||||
|
|
||||||
|
一键启动 Art Agent 的前端和后端服务,在**独立可见的终端窗口**中运行,
|
||||||
|
用户可以随时查看日志或手动关闭。
|
||||||
|
|
||||||
|
## 触发条件
|
||||||
|
|
||||||
|
当用户表达以下意图时触发:
|
||||||
|
- "启动项目"、"运行项目"、"跑起来"
|
||||||
|
- "打开前端和后端"、"启动服务"
|
||||||
|
- "start"、"launch"、"run dev"
|
||||||
|
- "启动后端"、"启动前端"(可单独启动其中一个)
|
||||||
|
|
||||||
|
## 项目路径
|
||||||
|
|
||||||
|
| 组件 | 路径 | 启动命令 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 后端 | `art-agent/backend` | `uvicorn app.main:app --reload --host 0.0.0.0 --port 8000` |
|
||||||
|
| 前端 | `art-agent/frontend` | `npm run dev` |
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
后端需要激活 Python 虚拟环境(`art-agent/backend/venv`)。
|
||||||
|
前端需要 Node.js >= 18。
|
||||||
|
|
||||||
|
## 环境 PATH 须知
|
||||||
|
|
||||||
|
本机 Node.js 安装在 `C:\Program Files\nodejs\` 但**未加入系统 PATH**。
|
||||||
|
新开的终端窗口默认找不到 `node` / `npm` 命令。
|
||||||
|
|
||||||
|
**解决方式**:在启动前端的命令中,先将 Node.js 路径注入到当前会话的 `$env:PATH` 中。
|
||||||
|
|
||||||
|
> 如果后续 Node.js 路径发生变化(如用户重新安装或使用 nvm),需要更新此处。
|
||||||
|
|
||||||
|
## 核心操作:启动服务
|
||||||
|
|
||||||
|
### 流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 确定项目根目录(workspace 根目录下的 art-agent/)
|
||||||
|
|
||||||
|
2. 确定操作系统和 Shell 类型(Windows / macOS / Linux)
|
||||||
|
|
||||||
|
3. 启动后端(在独立可见终端窗口中):
|
||||||
|
- Windows(PowerShell 或 CMD 均适用):
|
||||||
|
使用 `Start-Process` 或 `start cmd` 打开新的终端窗口
|
||||||
|
- macOS/Linux:
|
||||||
|
使用对应的终端打开方式
|
||||||
|
|
||||||
|
4. 启动前端(在另一个独立可见终端窗口中):
|
||||||
|
- 同样在新的终端窗口中启动
|
||||||
|
|
||||||
|
5. 确认两个服务正在运行,告知用户访问地址
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 启动命令
|
||||||
|
|
||||||
|
**关键要求**:必须在**新的、可见的终端窗口**中启动,不能在 Cursor 内置终端后台运行。
|
||||||
|
|
||||||
|
#### PowerShell 环境
|
||||||
|
|
||||||
|
启动后端:
|
||||||
|
```powershell
|
||||||
|
Start-Process powershell -ArgumentList '-NoExit', '-Command', "cd 'BACKEND_PATH'; .\venv\Scripts\Activate.ps1; uvicorn app.main:app --reload --host 0.0.0.0 --port 8000" -WindowStyle Normal
|
||||||
|
```
|
||||||
|
|
||||||
|
启动前端(注意注入 Node.js PATH):
|
||||||
|
```powershell
|
||||||
|
Start-Process powershell -ArgumentList '-NoExit', '-Command', "& { `$env:PATH = 'C:\Program Files\nodejs;' + `$env:PATH; `$Host.UI.RawUI.WindowTitle = 'Art Agent Frontend'; cd 'FRONTEND_PATH'; npm run dev }" -WindowStyle Normal
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CMD 环境
|
||||||
|
|
||||||
|
启动后端:
|
||||||
|
```cmd
|
||||||
|
start "Art Agent Backend" cmd /k "cd /d BACKEND_PATH && venv\Scripts\activate && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"
|
||||||
|
```
|
||||||
|
|
||||||
|
启动前端(注意注入 Node.js PATH):
|
||||||
|
```cmd
|
||||||
|
start "Art Agent Frontend" cmd /k "set PATH=C:\Program Files\nodejs;%PATH% && cd /d FRONTEND_PATH && npm run dev"
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS / Linux 启动命令
|
||||||
|
|
||||||
|
根据用户终端环境选择:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端
|
||||||
|
osascript -e 'tell application "Terminal" to do script "cd BACKEND_PATH && source venv/bin/activate && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000"'
|
||||||
|
|
||||||
|
# 前端
|
||||||
|
osascript -e 'tell application "Terminal" to do script "cd FRONTEND_PATH && npm run dev"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行注意事项
|
||||||
|
|
||||||
|
1. **路径拼接**:`BACKEND_PATH` 和 `FRONTEND_PATH` 必须替换为实际的绝对路径
|
||||||
|
2. **venv 存在性检查**:启动后端前先确认 `art-agent/backend/venv` 目录存在,
|
||||||
|
不存在时提醒用户先创建虚拟环境
|
||||||
|
3. **依赖检查**:如果 `node_modules` 不存在,先提醒用户执行 `npm install`
|
||||||
|
4. **窗口标题**:尽量为窗口设置有意义的标题(如 "Art Agent Backend"、"Art Agent Frontend"),
|
||||||
|
方便用户在任务栏中识别
|
||||||
|
5. **不使用 `block_until_ms: 0`**:不要用 Cursor 的后台命令方式,
|
||||||
|
那样窗口不可见,用户无法直接查看和关闭
|
||||||
|
|
||||||
|
## 单独启动
|
||||||
|
|
||||||
|
如果用户只说"启动前端"或"启动后端",只启动对应的服务即可,不需要全部启动。
|
||||||
|
|
||||||
|
## 访问信息
|
||||||
|
|
||||||
|
启动完成后告知用户:
|
||||||
|
- 后端 API:http://localhost:8000
|
||||||
|
- 后端文档:http://localhost:8000/docs
|
||||||
|
- 前端页面:http://localhost:3000
|
||||||
|
|
||||||
|
## 自迭代日志
|
||||||
|
|
||||||
|
本节记录使用本 Skill 过程中发现的必要检查项。
|
||||||
|
|
||||||
|
### 已知必要检查
|
||||||
|
|
||||||
|
1. **Node.js PATH 注入** — 本机 Node.js (`C:\Program Files\nodejs\`) 未加入系统 PATH,新开的终端窗口默认找不到 `npm`。启动前端时必须先将此路径注入到会话 PATH 中。
|
||||||
54
.gitignore
vendored
Normal file
54
.gitignore
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
# EPEEAIKit .gitignore
|
||||||
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ─── Python ──────────────────────────────────────────────
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# 虚拟环境
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# 分发 / 构建
|
||||||
|
*.egg-info/
|
||||||
|
*.egg
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# ─── Node.js / Next.js ──────────────────────────────────
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# ─── 环境变量 / 密钥(保留 .env.example 作为模板)───────
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# ─── 运行时生成物 ────────────────────────────────────────
|
||||||
|
art-agent/backend/uploads/
|
||||||
|
art-agent/backend/generated/
|
||||||
|
|
||||||
|
# ─── 工具二进制 ──────────────────────────────────────────
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# ─── IDE / 编辑器 ────────────────────────────────────────
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# ─── OS 文件 ─────────────────────────────────────────────
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# ─── 日志 / 调试 ─────────────────────────────────────────
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"AndreaNovelHelper.workspaceDisabled": true
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Art Agent MVP
|
# EPEEKit
|
||||||
|
|
||||||
AI 美术资源生成助手 — 通过对话生成游戏美术资源。
|
AI 美术资源生成工具集 — 通过对话生成游戏美术资源。
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ AI 美术资源生成助手 — 通过对话生成游戏美术资源。
|
|||||||
|
|
||||||
你需要两个 API Key:
|
你需要两个 API Key:
|
||||||
|
|
||||||
- **OpenAI API Key**:https://platform.openai.com/api-keys
|
- **OpenAI API Key**(或兼容 API):https://platform.openai.com/api-keys
|
||||||
- **Replicate API Token**:https://replicate.com/account/api-tokens
|
- **Replicate API Token**:https://replicate.com/account/api-tokens
|
||||||
|
|
||||||
### 2. 启动后端
|
### 2. 启动后端
|
||||||
@@ -26,10 +26,10 @@ pip install -r requirements.txt
|
|||||||
|
|
||||||
# 配置环境变量
|
# 配置环境变量
|
||||||
copy .env.example .env
|
copy .env.example .env
|
||||||
# 编辑 .env,填入你的 API Key
|
# 编辑 .env,填入你的 API Key 和模型配置
|
||||||
|
|
||||||
# 启动
|
# 启动(--host 0.0.0.0 允许局域网/穿透访问)
|
||||||
uvicorn app.main:app --reload --port 8000
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 启动前端
|
### 3. 启动前端
|
||||||
@@ -53,6 +53,43 @@ npm run dev
|
|||||||
- 生成的图片可以点击"保存"按钮下载
|
- 生成的图片可以点击"保存"按钮下载
|
||||||
- 持续对话进行迭代修改
|
- 持续对话进行迭代修改
|
||||||
|
|
||||||
|
## 访问地址
|
||||||
|
|
||||||
|
| 场景 | 前端页面 | 后端 API | 后端文档 |
|
||||||
|
|------|---------|---------|---------|
|
||||||
|
| **本机** | http://localhost:3000 | http://localhost:8000 | http://localhost:8000/docs |
|
||||||
|
| **局域网**(同一 WiFi) | `http://<你的IP>:3000` | `http://<你的IP>:8000` | `http://<你的IP>:8000/docs` |
|
||||||
|
| **外网**(Cloudflare Tunnel) | `https://xxx.trycloudflare.com`(每次启动不同) | `https://yyy.trycloudflare.com` | — |
|
||||||
|
|
||||||
|
查看本机局域网 IP:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows
|
||||||
|
Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -eq 'WLAN' }
|
||||||
|
|
||||||
|
# macOS / Linux
|
||||||
|
ifconfig | grep "inet " | grep -v 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
所有可配置项都集中在 `backend/.env` 文件中,分为以下几组:
|
||||||
|
|
||||||
|
| 配置组 | 变量 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **LLM 对话** | `OPENAI_API_KEY` | API 密钥 |
|
||||||
|
| | `OPENAI_BASE_URL` | API 基地址,支持任何 OpenAI 兼容 API |
|
||||||
|
| | `LLM_MODEL` | 模型名称,如 `gpt-4o-mini`、`deepseek-chat` 等 |
|
||||||
|
| | `LLM_MAX_ITERATIONS` | Agent 单轮最大工具调用次数 |
|
||||||
|
| **图像生成** | `REPLICATE_API_TOKEN` | Replicate API Token |
|
||||||
|
| | `IMAGE_MODEL` | 图像生成模型,如 `black-forest-labs/flux-schnell` |
|
||||||
|
| | `IMAGE_ASPECT_RATIO` | 默认宽高比,如 `1:1` |
|
||||||
|
| | `IMAGE_OUTPUT_FORMAT` | 输出格式,如 `png` |
|
||||||
|
| **网络** | `HTTP_PROXY` / `HTTPS_PROXY` | 代理配置 |
|
||||||
|
| **服务** | `PORT` | 后端服务端口 |
|
||||||
|
|
||||||
|
切换 LLM 供应商只需修改 `OPENAI_BASE_URL` + `OPENAI_API_KEY` + `LLM_MODEL` 三个值。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -60,6 +97,7 @@ art-agent/
|
|||||||
backend/ # Python FastAPI 后端
|
backend/ # Python FastAPI 后端
|
||||||
app/
|
app/
|
||||||
main.py # 入口
|
main.py # 入口
|
||||||
|
config.py # 集中配置(读取 .env)
|
||||||
api/chat.py # 对话 API
|
api/chat.py # 对话 API
|
||||||
agent/ # Agent Loop 核心
|
agent/ # Agent Loop 核心
|
||||||
services/ # AI 服务封装
|
services/ # AI 服务封装
|
||||||
@@ -71,8 +109,69 @@ art-agent/
|
|||||||
docs/ # 文档
|
docs/ # 文档
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 远程访问(外网穿透)
|
||||||
|
|
||||||
|
通过 Cloudflare Quick Tunnel 免费穿透内网,手机或朋友在任何网络下都能访问,无需买服务器。
|
||||||
|
|
||||||
|
### 安装 cloudflared
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Windows(任选一种)
|
||||||
|
winget install Cloudflare.cloudflared
|
||||||
|
|
||||||
|
# 或手动下载
|
||||||
|
# https://github.com/cloudflare/cloudflared/releases/latest
|
||||||
|
# 下载 cloudflared-windows-amd64.exe,放到项目目录下即可
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install cloudflared
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
# https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 一键启动穿透
|
||||||
|
|
||||||
|
确保前后端服务已经在运行,然后在另一个终端执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd art-agent
|
||||||
|
.\start-tunnel.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会自动:
|
||||||
|
1. 为后端和前端各创建一条穿透隧道
|
||||||
|
2. 更新 `frontend/.env.local` 中的后端 API 地址为穿透地址
|
||||||
|
3. 打印手机可访问的公网 HTTPS 链接
|
||||||
|
4. 关闭脚本时(Ctrl+C)自动恢复 `.env.local` 为 localhost
|
||||||
|
|
||||||
|
> **重要**:穿透启动后需要**重启前端** (`npm run dev`) 才能读取新的 `.env.local`。
|
||||||
|
|
||||||
|
### 穿透管理
|
||||||
|
|
||||||
|
| 操作 | 方式 |
|
||||||
|
|------|------|
|
||||||
|
| **启动** | 运行 `.\start-tunnel.ps1`,等待打印出两条 `trycloudflare.com` 地址 |
|
||||||
|
| **停止** | 在脚本窗口按 `Ctrl+C`,或在任务管理器中结束 `cloudflared.exe` 进程 |
|
||||||
|
| **查看状态** | 检查日志:`Get-Content $env:TEMP\epeekit-tunnel-backend.log` |
|
||||||
|
| **重启** | 先停止再启动,地址会变(Quick Tunnel 每次分配随机域名) |
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- Quick Tunnel **免费无需账号**,但地址每次启动都会变化
|
||||||
|
- 如果 cloudflared 连接超时,可能需要配置代理(Clash 等),在运行脚本前设置:
|
||||||
|
```powershell
|
||||||
|
$env:HTTP_PROXY = "http://127.0.0.1:7890"
|
||||||
|
$env:HTTPS_PROXY = "http://127.0.0.1:7890"
|
||||||
|
.\start-tunnel.ps1
|
||||||
|
```
|
||||||
|
- 电脑关机、休眠或代理断开后隧道会自动失效
|
||||||
|
- 停止穿透后,`start-tunnel.ps1` 会自动将 `.env.local` 恢复为 `localhost`;如果手动杀进程,需要自行恢复
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 前端:Next.js + TypeScript + Tailwind CSS
|
- 前端:Next.js + TypeScript + Tailwind CSS
|
||||||
- 后端:Python FastAPI + SSE
|
- 后端:Python FastAPI + SSE
|
||||||
- AI:OpenAI GPT-4o-mini(对话)+ Replicate Flux(图像生成)
|
- AI:可配置 LLM(默认 GPT-4o-mini)+ 可配置图像模型(默认 Replicate Flux)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
# OpenAI API Key
|
|
||||||
# 获取地址: https://platform.openai.com/api-keys
|
|
||||||
OPENAI_API_KEY=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
|
|
||||||
|
|
||||||
# Replicate API Token
|
|
||||||
# 获取地址: https://replicate.com/account/api-tokens
|
|
||||||
REPLICATE_API_TOKEN=r8_dVRCon51vpyThJf0Nu8IqHUUhptQOEY04QVuN
|
|
||||||
|
|
||||||
# 后端服务端口
|
|
||||||
PORT=8000
|
|
||||||
45
art-agent/backend/.env.example
Normal file
45
art-agent/backend/.env.example
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# ╔═══════════════════════════════════════════════════════════╗
|
||||||
|
# ║ EPEEKit — 集中配置 ║
|
||||||
|
# ║ 切换 LLM 只需改 OPENAI_BASE_URL + OPENAI_API_KEY ║
|
||||||
|
# ║ + LLM_MODEL 三个值 ║
|
||||||
|
# ╚═══════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
# ─── LLM 对话配置 ─────────────────────────────────────────
|
||||||
|
# API 密钥(OpenAI 或兼容 API 的 key)
|
||||||
|
OPENAI_API_KEY=sk-your-key-here
|
||||||
|
|
||||||
|
# API 基地址(留空 = 直连 api.openai.com)
|
||||||
|
# 常见替代值:
|
||||||
|
# OpenRouter: https://openrouter.ai/api/v1
|
||||||
|
# DeepSeek: https://api.deepseek.com/v1
|
||||||
|
# 本地 Ollama: http://localhost:11434/v1
|
||||||
|
# OPENAI_BASE_URL=
|
||||||
|
|
||||||
|
# 对话模型名称
|
||||||
|
# 常见选项: gpt-4o-mini | gpt-4o | deepseek-chat | claude-3-haiku-20240307
|
||||||
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# Agent 单轮最大工具调用次数(防止无限循环)
|
||||||
|
LLM_MAX_ITERATIONS=5
|
||||||
|
|
||||||
|
# ─── 图像生成配置 ─────────────────────────────────────────
|
||||||
|
# Replicate API Token
|
||||||
|
REPLICATE_API_TOKEN=your-replicate-token-here
|
||||||
|
|
||||||
|
# 默认图像生成模型(注册表短 ID,前端未指定时的 fallback)
|
||||||
|
# 可选值: flux-schnell | flux-dev | sdxl
|
||||||
|
# 也兼容完整 Replicate model ID 如 black-forest-labs/flux-schnell
|
||||||
|
IMAGE_MODEL=flux-schnell
|
||||||
|
|
||||||
|
# 默认宽高比
|
||||||
|
IMAGE_ASPECT_RATIO=1:1
|
||||||
|
|
||||||
|
# 输出格式
|
||||||
|
IMAGE_OUTPUT_FORMAT=png
|
||||||
|
|
||||||
|
# ─── 网络代理(按需配置)─────────────────────────────────
|
||||||
|
# HTTP_PROXY=http://127.0.0.1:7890
|
||||||
|
# HTTPS_PROXY=http://127.0.0.1:7890
|
||||||
|
|
||||||
|
# ─── 服务配置 ─────────────────────────────────────────────
|
||||||
|
PORT=8000
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,15 +1,19 @@
|
|||||||
"""Agent Loop 主循环:LLM 对话 -> 工具调用 -> 结果回传 -> 继续。"""
|
"""Agent Loop 主循环:LLM 对话 -> 工具调用 -> 结果回传 -> 继续。"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from typing import AsyncGenerator, Optional
|
from typing import AsyncGenerator, Optional
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
from app.agent.tools import TOOL_DEFINITIONS, execute_tool
|
from app.agent.tools import TOOL_DEFINITIONS, execute_tool
|
||||||
|
from app.config import get_llm_model, get_llm_max_iterations
|
||||||
|
from app.services.image_gen import to_data_uri
|
||||||
|
|
||||||
|
|
||||||
def _get_client() -> AsyncOpenAI:
|
def _get_client() -> AsyncOpenAI:
|
||||||
return AsyncOpenAI()
|
base_url = os.getenv("OPENAI_BASE_URL")
|
||||||
|
return AsyncOpenAI(base_url=base_url) if base_url else AsyncOpenAI()
|
||||||
|
|
||||||
SYSTEM_PROMPT = """\
|
SYSTEM_PROMPT = """\
|
||||||
你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。
|
你是一个专业的游戏美术 AI 助手。你的工作是帮助美术人员通过对话生成游戏美术资源。
|
||||||
@@ -19,6 +23,7 @@ SYSTEM_PROMPT = """\
|
|||||||
- 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt
|
- 理解用户的审美意图,将中文描述转化为高质量的英文生成 prompt
|
||||||
- 根据用户反馈迭代修改(调整颜色、风格、构图等)
|
- 根据用户反馈迭代修改(调整颜色、风格、构图等)
|
||||||
- 如果用户提供了参考图,将参考图的风格元素融入生成 prompt
|
- 如果用户提供了参考图,将参考图的风格元素融入生成 prompt
|
||||||
|
- 理解用户在图片上的标注(框选区域 + 文字批注),精准定位需要修改的部分
|
||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
|
1. 理解用户需求,必要时追问细节(尺寸、风格、用途等)
|
||||||
@@ -26,11 +31,20 @@ SYSTEM_PROMPT = """\
|
|||||||
3. 向用户展示结果并询问反馈
|
3. 向用户展示结果并询问反馈
|
||||||
4. 根据反馈调整 prompt 并重新生成
|
4. 根据反馈调整 prompt 并重新生成
|
||||||
|
|
||||||
|
## 标注理解
|
||||||
|
当用户发送带有「图片标注」的消息时,表示用户在之前生成的图片上做了标注。
|
||||||
|
标注格式为:`[区域 (x%, y%) 大小 w%×h%]: 修改意见`
|
||||||
|
- 区域坐标表示标注框在图片上的相对位置
|
||||||
|
- 你需要理解标注区域所指的图片内容,并将修改意见融入新的 prompt
|
||||||
|
- 如果附带了标注截图(参考图),仔细观察红色标注框和文字来理解用户意图
|
||||||
|
- 在调整 prompt 时,保持原图整体风格不变,只针对标注区域做修改
|
||||||
|
|
||||||
## 生成 prompt 要求
|
## 生成 prompt 要求
|
||||||
- 必须使用英文
|
- 必须使用英文
|
||||||
- 尽量详细描述:主体内容、风格、颜色方案、光照、构图、材质等
|
- 尽量详细描述:主体内容、颜色方案、光照、构图、材质等
|
||||||
- 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button" 等
|
- 如果用户要求游戏 UI 元素,添加相关关键词如 "game UI", "icon", "button" 等
|
||||||
- 如果用户提供了参考图,在 prompt 中描述参考图的风格特征
|
- 如果你能直接看到参考图(图片内容),可以在 prompt 中描述参考图的风格特征
|
||||||
|
- 如果你无法看到参考图(只收到了文字提示说有参考图),参考图会由生图工具的 IP-Adapter 自动处理风格融合。此时你的 prompt 中**绝对不要猜测或指定任何画风/艺术风格关键词**(如 pixel art、watercolor、oil painting 等),只描述画面内容(主体、构图、光照等),把风格完全交给参考图来决定
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
- 用中文和用户交流
|
- 用中文和用户交流
|
||||||
@@ -42,6 +56,7 @@ SYSTEM_PROMPT = """\
|
|||||||
async def run_agent_loop(
|
async def run_agent_loop(
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
ref_image_url: Optional[str] = None,
|
ref_image_url: Optional[str] = None,
|
||||||
|
image_model: Optional[str] = None,
|
||||||
) -> AsyncGenerator[dict, None]:
|
) -> AsyncGenerator[dict, None]:
|
||||||
"""
|
"""
|
||||||
运行 Agent Loop,以 SSE 事件流形式 yield 结果。
|
运行 Agent Loop,以 SSE 事件流形式 yield 结果。
|
||||||
@@ -56,33 +71,45 @@ async def run_agent_loop(
|
|||||||
# 构建消息列表
|
# 构建消息列表
|
||||||
api_messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
api_messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||||
|
|
||||||
|
# 检测当前 LLM 是否支持 vision(多模态图片输入)
|
||||||
|
llm_model = get_llm_model().lower()
|
||||||
|
vision_capable = any(kw in llm_model for kw in ("gpt-4o", "gpt-4-vision", "claude"))
|
||||||
|
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
if msg["role"] == "user" and ref_image_url:
|
if msg["role"] == "user" and ref_image_url and msg is messages[-1]:
|
||||||
# 最后一条用户消息附加参考图(GPT-4o vision)
|
if vision_capable:
|
||||||
if msg == messages[-1] or (
|
# 支持 vision 的模型:直接发送图片
|
||||||
msg.get("role") == "user"
|
|
||||||
and messages.index(msg) == len(messages) - 1
|
|
||||||
):
|
|
||||||
api_messages.append({
|
api_messages.append({
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": [
|
"content": [
|
||||||
{"type": "text", "text": msg["content"]},
|
{"type": "text", "text": msg["content"]},
|
||||||
{
|
{
|
||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
"image_url": {"url": ref_image_url},
|
"image_url": {"url": to_data_uri(ref_image_url)},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
continue
|
else:
|
||||||
|
# 不支持 vision 的模型:以文字提示告知有参考图,风格由 IP-Adapter 处理
|
||||||
|
hint = (
|
||||||
|
f"{msg['content']}\n\n"
|
||||||
|
"【系统提示:用户上传了一张参考图,已自动传递给图片生成工具的 IP-Adapter。"
|
||||||
|
"IP-Adapter 会从参考图中提取风格并融合到生成结果中。"
|
||||||
|
"你无法看到这张参考图,因此在生成 prompt 时:\n"
|
||||||
|
"1. 只描述画面内容(主体、构图、光照、材质等)\n"
|
||||||
|
"2. 不要猜测或添加任何画风/艺术风格关键词(如 pixel art、watercolor、cartoon 等)\n"
|
||||||
|
"3. 风格完全由参考图通过 IP-Adapter 决定】"
|
||||||
|
)
|
||||||
|
api_messages.append({"role": "user", "content": hint})
|
||||||
|
continue
|
||||||
api_messages.append({"role": msg["role"], "content": msg["content"]})
|
api_messages.append({"role": msg["role"], "content": msg["content"]})
|
||||||
|
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
max_iterations = 5
|
for _ in range(get_llm_max_iterations()):
|
||||||
for _ in range(max_iterations):
|
|
||||||
try:
|
try:
|
||||||
response = await client.chat.completions.create(
|
response = await client.chat.completions.create(
|
||||||
model="gpt-4o-mini",
|
model=get_llm_model(),
|
||||||
messages=api_messages,
|
messages=api_messages,
|
||||||
tools=TOOL_DEFINITIONS,
|
tools=TOOL_DEFINITIONS,
|
||||||
stream=True,
|
stream=True,
|
||||||
@@ -163,13 +190,28 @@ async def run_agent_loop(
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
arguments = {}
|
arguments = {}
|
||||||
|
|
||||||
result = await execute_tool(tool_name, arguments, ref_image_url)
|
result = await execute_tool(tool_name, arguments, ref_image_url, image_model)
|
||||||
|
|
||||||
|
used_model = result.get("model_name", "")
|
||||||
|
|
||||||
|
if result.get("errors"):
|
||||||
|
yield {
|
||||||
|
"type": "tool_error",
|
||||||
|
"data": {
|
||||||
|
"tool": tool_name,
|
||||||
|
"errors": result["errors"],
|
||||||
|
"model_name": used_model,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# 如果有图片结果,推送给前端
|
|
||||||
if result.get("images"):
|
if result.get("images"):
|
||||||
yield {
|
yield {
|
||||||
"type": "image_result",
|
"type": "image_result",
|
||||||
"data": {"images": result["images"]},
|
"data": {
|
||||||
|
"images": result["images"],
|
||||||
|
"prompt_used": result.get("prompt_used", ""),
|
||||||
|
"model_name": used_model,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# 工具结果回传给 LLM
|
# 工具结果回传给 LLM
|
||||||
@@ -179,7 +221,7 @@ async def run_agent_loop(
|
|||||||
"content": json.dumps(result, ensure_ascii=False),
|
"content": json.dumps(result, ensure_ascii=False),
|
||||||
})
|
})
|
||||||
|
|
||||||
# 清除 ref_image_url,避免后续轮次重复附加
|
# ref_image_url 不清除:消息构建只在循环外执行一次,
|
||||||
ref_image_url = None
|
# 后续工具调用仍需参考图(InstantStyle 等模型必须有 style_image)
|
||||||
|
|
||||||
yield {"type": "done", "data": {}}
|
yield {"type": "done", "data": {}}
|
||||||
|
|||||||
@@ -38,20 +38,27 @@ async def execute_tool(
|
|||||||
tool_name: str,
|
tool_name: str,
|
||||||
arguments: dict,
|
arguments: dict,
|
||||||
ref_image_url: str | None = None,
|
ref_image_url: str | None = None,
|
||||||
|
image_model: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""执行工具调用,返回结果。"""
|
"""执行工具调用,返回结果。"""
|
||||||
if tool_name == "generate_image":
|
if tool_name == "generate_image":
|
||||||
prompt = arguments["prompt"]
|
prompt = arguments["prompt"]
|
||||||
num_images = arguments.get("num_images", 1)
|
num_images = arguments.get("num_images", 1)
|
||||||
image_urls = await generate_images(
|
result = await generate_images(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
num_images=num_images,
|
num_images=num_images,
|
||||||
ref_image_url=ref_image_url,
|
ref_image_url=ref_image_url,
|
||||||
|
model_id=image_model,
|
||||||
)
|
)
|
||||||
|
valid_urls = [u for u in result.urls if not u.startswith("[")]
|
||||||
|
errors = [u for u in result.urls if u.startswith("[")]
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": len(valid_urls) > 0,
|
||||||
"images": image_urls,
|
"images": valid_urls,
|
||||||
|
"errors": errors,
|
||||||
"prompt_used": prompt,
|
"prompt_used": prompt,
|
||||||
|
"model_name": result.model_name,
|
||||||
|
"model_id": result.model_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {"success": False, "error": f"未知工具: {tool_name}"}
|
return {"success": False, "error": f"未知工具: {tool_name}"}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -7,6 +7,7 @@ from fastapi import APIRouter, File, Form, UploadFile
|
|||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from app.agent.loop import run_agent_loop
|
from app.agent.loop import run_agent_loop
|
||||||
|
from app.config import get_image_models_list, get_default_image_model_id
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -23,26 +24,56 @@ async def _save_upload(file: UploadFile) -> str:
|
|||||||
return f"/uploads/{filename}"
|
return f"/uploads/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-ref-image")
|
||||||
|
async def upload_ref_image(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
独立的参考图上传端点。
|
||||||
|
前端选图后立即调用,返回服务端路径,供后续发消息时引用。
|
||||||
|
"""
|
||||||
|
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
url = await _save_upload(file)
|
||||||
|
return {"url": url, "filename": file.filename}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models")
|
||||||
|
async def list_models():
|
||||||
|
"""返回可用的图像生成模型列表。"""
|
||||||
|
return {
|
||||||
|
"models": get_image_models_list(),
|
||||||
|
"default": get_default_image_model_id(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat")
|
@router.post("/chat")
|
||||||
async def chat(
|
async def chat(
|
||||||
messages: str = Form(...),
|
messages: str = Form(...),
|
||||||
ref_image: Optional[UploadFile] = File(None),
|
ref_image: Optional[UploadFile] = File(None),
|
||||||
|
ref_image_url: Optional[str] = Form(None),
|
||||||
|
image_model: Optional[str] = Form(None),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
主对话端点。
|
主对话端点。
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
- messages: JSON 字符串,对话历史 [{role, content}]
|
- messages: JSON 字符串,对话历史 [{role, content}]
|
||||||
- ref_image: 可选的参考图文件
|
- ref_image: 可选的参考图文件(兼容旧方式)
|
||||||
|
- ref_image_url: 可选,已通过 /upload-ref-image 上传后的服务端路径
|
||||||
|
- image_model: 可选,指定本次使用的生图模型短 ID
|
||||||
"""
|
"""
|
||||||
parsed_messages = json.loads(messages)
|
parsed_messages = json.loads(messages)
|
||||||
|
|
||||||
ref_image_url: Optional[str] = None
|
resolved_ref_url: Optional[str] = None
|
||||||
if ref_image and ref_image.filename:
|
if ref_image_url:
|
||||||
ref_image_url = await _save_upload(ref_image)
|
resolved_ref_url = ref_image_url
|
||||||
|
elif ref_image and ref_image.filename:
|
||||||
|
resolved_ref_url = await _save_upload(ref_image)
|
||||||
|
|
||||||
async def event_generator():
|
async def event_generator():
|
||||||
async for event in run_agent_loop(parsed_messages, ref_image_url):
|
async for event in run_agent_loop(
|
||||||
|
parsed_messages, resolved_ref_url, image_model=image_model
|
||||||
|
):
|
||||||
yield {
|
yield {
|
||||||
"event": event["type"],
|
"event": event["type"],
|
||||||
"data": json.dumps(event["data"], ensure_ascii=False),
|
"data": json.dumps(event["data"], ensure_ascii=False),
|
||||||
|
|||||||
156
art-agent/backend/app/config.py
Normal file
156
art-agent/backend/app/config.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""
|
||||||
|
EPEEKit 集中配置。
|
||||||
|
所有可调参数从环境变量读取,每次调用实时读取(不缓存),确保 load_dotenv() 后生效。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_model() -> str:
|
||||||
|
return os.getenv("LLM_MODEL", "gpt-4o-mini")
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_max_iterations() -> int:
|
||||||
|
return int(os.getenv("LLM_MAX_ITERATIONS", "5"))
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 图像模型注册表 ─────────────────────────────────────
|
||||||
|
#
|
||||||
|
# 每个模型的配置说明:
|
||||||
|
# id — 前端/API 使用的短 ID
|
||||||
|
# name — 显示名称
|
||||||
|
# provider — 生成服务提供者(对应 image_gen.py 中的 Provider)
|
||||||
|
# model_id — Replicate 上的完整模型 ID
|
||||||
|
# description — 前端下拉列表中的说明文字
|
||||||
|
# supports_ref_image — 是否原生支持参考图输入(IP-Adapter 等)
|
||||||
|
# ref_image_param — 传给 Replicate 的参考图参数名(模型间可能不同)
|
||||||
|
# num_images_param — 批量生成参数名(Flux 用 num_outputs,Kolors 用 number_of_images)
|
||||||
|
# default_params — 默认推理参数
|
||||||
|
|
||||||
|
IMAGE_MODELS: dict[str, dict[str, Any]] = {
|
||||||
|
"flux-schnell": {
|
||||||
|
"id": "flux-schnell",
|
||||||
|
"name": "Flux Schnell",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "black-forest-labs/flux-schnell",
|
||||||
|
"description": "快速生成,适合快速迭代",
|
||||||
|
"supports_ref_image": False,
|
||||||
|
"num_images_param": "num_outputs",
|
||||||
|
"default_params": {
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"output_format": "png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"flux-dev": {
|
||||||
|
"id": "flux-dev",
|
||||||
|
"name": "Flux Dev",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "black-forest-labs/flux-dev",
|
||||||
|
"description": "高质量生成,细节更好",
|
||||||
|
"supports_ref_image": False,
|
||||||
|
"num_images_param": "num_outputs",
|
||||||
|
"default_params": {
|
||||||
|
"aspect_ratio": "1:1",
|
||||||
|
"output_format": "png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"sdxl": {
|
||||||
|
"id": "sdxl",
|
||||||
|
"name": "Stable Diffusion XL",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "stability-ai/sdxl",
|
||||||
|
"description": "经典 SDXL,支持 negative prompt",
|
||||||
|
"supports_ref_image": False,
|
||||||
|
"num_images_param": "num_outputs",
|
||||||
|
"default_params": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"num_inference_steps": 50,
|
||||||
|
"guidance_scale": 7.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"instant-style": {
|
||||||
|
"id": "instant-style",
|
||||||
|
"name": "InstantStyle",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "jyoung105/instant-style:c6f01e12f31cb99f9ee774a78992a71294f630a6f433d9aecfdc33b816fc4baa",
|
||||||
|
"description": "强风格迁移,画风还原度高(较慢)",
|
||||||
|
"supports_ref_image": True,
|
||||||
|
"ref_image_param": "style_image",
|
||||||
|
"num_images_param": "num_outputs",
|
||||||
|
"default_params": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"num_inference_steps": 30,
|
||||||
|
"guidance_scale": 5,
|
||||||
|
"style_strength": 1.0,
|
||||||
|
"block_mode": "style-only",
|
||||||
|
"adapter_mode": "original",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kolors-ipadapter": {
|
||||||
|
"id": "kolors-ipadapter",
|
||||||
|
"name": "Kolors IP-Adapter",
|
||||||
|
"provider": "replicate",
|
||||||
|
"model_id": "fofr/kolors-with-ipadapter:5a1a92b2c0f81813225d48ed8e411813da41aa84e7582fb705d1af46eea36eed",
|
||||||
|
"description": "风格参考生成,上传参考图效果最佳",
|
||||||
|
"supports_ref_image": True,
|
||||||
|
"ref_image_param": "image",
|
||||||
|
"num_images_param": "number_of_images",
|
||||||
|
"default_params": {
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024,
|
||||||
|
"steps": 25,
|
||||||
|
"cfg": 4,
|
||||||
|
"ip_adapter_weight": 0.8,
|
||||||
|
"ip_adapter_weight_type": "style transfer precise",
|
||||||
|
"output_format": "png",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_image_model_id() -> str:
|
||||||
|
"""返回 .env 中配置的默认模型短 ID,若不在注册表中则回退到 flux-schnell。"""
|
||||||
|
env_model = os.getenv("IMAGE_MODEL", "flux-schnell")
|
||||||
|
for mid, cfg in IMAGE_MODELS.items():
|
||||||
|
if cfg["model_id"] == env_model or mid == env_model:
|
||||||
|
return mid
|
||||||
|
return "flux-schnell"
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_model_config(model_id: str | None = None) -> dict[str, Any]:
|
||||||
|
"""根据短 ID 获取模型配置,未指定或不存在则使用默认模型。"""
|
||||||
|
if model_id and model_id in IMAGE_MODELS:
|
||||||
|
return IMAGE_MODELS[model_id]
|
||||||
|
return IMAGE_MODELS[get_default_image_model_id()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_ref_image_model_id() -> str | None:
|
||||||
|
"""返回有参考图时推荐使用的模型 ID(第一个 supports_ref_image=True 的模型)。"""
|
||||||
|
for mid, cfg in IMAGE_MODELS.items():
|
||||||
|
if cfg.get("supports_ref_image"):
|
||||||
|
return mid
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_models_list() -> list[dict]:
|
||||||
|
"""返回前端下拉列表所需的模型摘要信息。"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": cfg["id"],
|
||||||
|
"name": cfg["name"],
|
||||||
|
"description": cfg["description"],
|
||||||
|
"supports_ref_image": cfg.get("supports_ref_image", False),
|
||||||
|
}
|
||||||
|
for cfg in IMAGE_MODELS.values()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_aspect_ratio() -> str:
|
||||||
|
return os.getenv("IMAGE_ASPECT_RATIO", "1:1")
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_output_format() -> str:
|
||||||
|
return os.getenv("IMAGE_OUTPUT_FORMAT", "png")
|
||||||
@@ -2,15 +2,15 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from app.api.chat import router as chat_router
|
from app.api.chat import router as chat_router
|
||||||
|
|
||||||
load_dotenv()
|
app = FastAPI(title="EPEEKit API")
|
||||||
|
|
||||||
app = FastAPI(title="Art Agent MVP")
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,76 +1,220 @@
|
|||||||
"""Replicate 图像生成服务封装。"""
|
"""图像生成服务 — Provider 抽象层。"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import replicate
|
from replicate import Client as ReplicateClient
|
||||||
|
|
||||||
GENERATED_DIR = Path(__file__).parent.parent.parent / "generated"
|
from app.config import get_image_model_config, get_image_aspect_ratio, get_image_output_format
|
||||||
|
|
||||||
|
BACKEND_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
GENERATED_DIR = BACKEND_ROOT / "generated"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_replicate_client() -> ReplicateClient:
|
||||||
|
"""
|
||||||
|
创建 Replicate 客户端。
|
||||||
|
Replicate SDK 内部创建 httpx transport 时不读取代理环境变量,
|
||||||
|
需要我们手动把代理配置注入到 transport 中。
|
||||||
|
"""
|
||||||
|
timeout = httpx.Timeout(5.0, read=300.0, write=30.0, connect=30.0, pool=10.0)
|
||||||
|
proxy_url = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||||||
|
|
||||||
|
transport_kwargs: dict[str, Any] = {}
|
||||||
|
if proxy_url:
|
||||||
|
transport_kwargs["proxy"] = proxy_url
|
||||||
|
|
||||||
|
our_transport = httpx.AsyncHTTPTransport(**transport_kwargs)
|
||||||
|
|
||||||
|
client = ReplicateClient(
|
||||||
|
timeout=timeout,
|
||||||
|
transport=our_transport,
|
||||||
|
)
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
_replicate_client = _make_replicate_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 公共工具 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def to_data_uri(image_path: str) -> str:
|
||||||
|
"""将本地路径或已有 URL 转为 Replicate 可接受的格式(data URI 或原始 URL)。"""
|
||||||
|
if image_path.startswith("data:") or image_path.startswith("http"):
|
||||||
|
return image_path
|
||||||
|
local = BACKEND_ROOT / image_path.lstrip("/")
|
||||||
|
if local.exists():
|
||||||
|
mime = mimetypes.guess_type(str(local))[0] or "image/png"
|
||||||
|
b64 = base64.b64encode(local.read_bytes()).decode()
|
||||||
|
return f"data:{mime};base64,{b64}"
|
||||||
|
return image_path
|
||||||
|
|
||||||
|
|
||||||
async def _download_image(url: str) -> str:
|
async def _download_image(url: str) -> str:
|
||||||
"""下载远程图片到本地 generated/ 目录,返回本地 URL 路径。"""
|
"""下载远程图片到本地 generated/ 目录,返回本地 URL 路径。"""
|
||||||
filename = f"{uuid.uuid4().hex}.png"
|
filename = f"{uuid.uuid4().hex}.png"
|
||||||
filepath = GENERATED_DIR / filename
|
filepath = GENERATED_DIR / filename
|
||||||
async with httpx.AsyncClient() as client:
|
proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY")
|
||||||
|
async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(60.0)) as client:
|
||||||
resp = await client.get(url, follow_redirects=True)
|
resp = await client.get(url, follow_redirects=True)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
filepath.write_bytes(resp.content)
|
filepath.write_bytes(resp.content)
|
||||||
return f"/generated/{filename}"
|
return f"/generated/{filename}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Provider 抽象基类 ─────────────────────────────────
|
||||||
|
|
||||||
|
class ImageProvider(ABC):
|
||||||
|
"""所有图像生成 provider 的基类。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 1,
|
||||||
|
ref_image_url: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
"""生成图片并返回本地 URL 列表。"""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ReplicateProvider(ImageProvider):
|
||||||
|
"""通过 Replicate API 调用模型。"""
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 1,
|
||||||
|
ref_image_url: str | None = None,
|
||||||
|
) -> list[str]:
|
||||||
|
replicate_model_id = model_config["model_id"]
|
||||||
|
default_params: dict[str, Any] = model_config.get("default_params", {})
|
||||||
|
|
||||||
|
local_urls: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
input_params = self._build_input(
|
||||||
|
model_config, default_params, prompt, num_images, ref_image_url
|
||||||
|
)
|
||||||
|
|
||||||
|
output = await _replicate_client.async_run(
|
||||||
|
replicate_model_id, input=input_params, wait=False
|
||||||
|
)
|
||||||
|
|
||||||
|
items = output if isinstance(output, list) else [output]
|
||||||
|
for item in items:
|
||||||
|
url = str(item)
|
||||||
|
if url.startswith("https://") or url.startswith("http://") or url.startswith("data:"):
|
||||||
|
local_urls.append(await _download_image(url))
|
||||||
|
else:
|
||||||
|
local_urls.append(f"[生成失败: 模型返回非图片内容: {url[:200]}]")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
detail = str(e) or f"{type(e).__name__}: {repr(e)}"
|
||||||
|
local_urls.append(f"[生成失败: {detail}]")
|
||||||
|
|
||||||
|
return local_urls
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_input(
|
||||||
|
model_config: dict[str, Any],
|
||||||
|
default_params: dict[str, Any],
|
||||||
|
prompt: str,
|
||||||
|
num_images: int = 1,
|
||||||
|
ref_image_url: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
根据模型配置构建 Replicate input 参数。
|
||||||
|
|
||||||
|
通过 model_config 中的标志字段自动适配不同模型:
|
||||||
|
- supports_ref_image / ref_image_param: 参考图注入
|
||||||
|
- num_images_param: 各模型的批量生成参数名(如 number_of_images / num_outputs)
|
||||||
|
"""
|
||||||
|
model_id = model_config["model_id"]
|
||||||
|
supports_ref = model_config.get("supports_ref_image", False)
|
||||||
|
ref_param_name = model_config.get("ref_image_param", "image")
|
||||||
|
num_images_param = model_config.get("num_images_param", "num_outputs")
|
||||||
|
|
||||||
|
# ── 支持参考图的模型(IP-Adapter 系列)──
|
||||||
|
if supports_ref:
|
||||||
|
params: dict[str, Any] = {"prompt": prompt}
|
||||||
|
for k, v in default_params.items():
|
||||||
|
params[k] = v
|
||||||
|
params[num_images_param] = num_images
|
||||||
|
if ref_image_url:
|
||||||
|
params[ref_param_name] = to_data_uri(ref_image_url)
|
||||||
|
return params
|
||||||
|
|
||||||
|
# ── Flux 系列(纯文生图)──
|
||||||
|
is_flux = "flux" in model_id.lower()
|
||||||
|
params = {"prompt": prompt, num_images_param: num_images}
|
||||||
|
|
||||||
|
if is_flux:
|
||||||
|
params["aspect_ratio"] = default_params.get(
|
||||||
|
"aspect_ratio", get_image_aspect_ratio()
|
||||||
|
)
|
||||||
|
params["output_format"] = default_params.get(
|
||||||
|
"output_format", get_image_output_format()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# SDXL 类模型
|
||||||
|
params["width"] = default_params.get("width", 1024)
|
||||||
|
params["height"] = default_params.get("height", 1024)
|
||||||
|
if "num_inference_steps" in default_params:
|
||||||
|
params["num_inference_steps"] = default_params["num_inference_steps"]
|
||||||
|
if "guidance_scale" in default_params:
|
||||||
|
params["guidance_scale"] = default_params["guidance_scale"]
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Provider 注册 ─────────────────────────────────────
|
||||||
|
|
||||||
|
_PROVIDERS: dict[str, ImageProvider] = {
|
||||||
|
"replicate": ReplicateProvider(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 公开 API ──────────────────────────────────────────
|
||||||
|
|
||||||
|
class GenerateResult:
|
||||||
|
"""图片生成结果,包含生成的 URL 列表和实际使用的模型信息。"""
|
||||||
|
|
||||||
|
def __init__(self, urls: list[str], model_name: str, model_id: str):
|
||||||
|
self.urls = urls
|
||||||
|
self.model_name = model_name
|
||||||
|
self.model_id = model_id
|
||||||
|
|
||||||
|
|
||||||
async def generate_images(
|
async def generate_images(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
num_images: int = 1,
|
num_images: int = 1,
|
||||||
ref_image_url: str | None = None,
|
ref_image_url: str | None = None,
|
||||||
) -> list[str]:
|
model_id: str | None = None,
|
||||||
|
) -> GenerateResult:
|
||||||
"""
|
"""
|
||||||
调用 Replicate 生成图片。
|
统一入口:根据 model_id 查注册表,分发到对应 provider。
|
||||||
|
|
||||||
返回本地可访问的图片 URL 列表。
|
model_id 为空时使用 .env 中配置的默认模型。
|
||||||
"""
|
"""
|
||||||
local_urls = []
|
config = get_image_model_config(model_id)
|
||||||
|
provider_name = config.get("provider", "replicate")
|
||||||
|
provider = _PROVIDERS.get(provider_name)
|
||||||
|
|
||||||
for _ in range(num_images):
|
model_name = config.get("name", "未知模型")
|
||||||
try:
|
resolved_id = config.get("id", model_id or "unknown")
|
||||||
if ref_image_url and ref_image_url.startswith("/"):
|
|
||||||
# 本地路径转为 file URI 不适用于 Replicate,
|
|
||||||
# 需要用户上传的图先通过后端 URL 访问
|
|
||||||
# MVP 阶段:参考图作为 prompt 的文字补充,不直接传给模型
|
|
||||||
# 仅使用 flux-schnell 文生图
|
|
||||||
output = await replicate.async_run(
|
|
||||||
"black-forest-labs/flux-schnell",
|
|
||||||
input={
|
|
||||||
"prompt": prompt,
|
|
||||||
"num_outputs": 1,
|
|
||||||
"aspect_ratio": "1:1",
|
|
||||||
"output_format": "png",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
output = await replicate.async_run(
|
|
||||||
"black-forest-labs/flux-schnell",
|
|
||||||
input={
|
|
||||||
"prompt": prompt,
|
|
||||||
"num_outputs": 1,
|
|
||||||
"aspect_ratio": "1:1",
|
|
||||||
"output_format": "png",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# output 是 FileOutput 列表或单个 URL
|
if not provider:
|
||||||
if isinstance(output, list):
|
return GenerateResult([f"[未知 provider: {provider_name}]"], model_name, resolved_id)
|
||||||
for item in output:
|
|
||||||
url = str(item)
|
|
||||||
local_url = await _download_image(url)
|
|
||||||
local_urls.append(local_url)
|
|
||||||
else:
|
|
||||||
url = str(output)
|
|
||||||
local_url = await _download_image(url)
|
|
||||||
local_urls.append(local_url)
|
|
||||||
|
|
||||||
except Exception as e:
|
urls = await provider.generate(config, prompt, num_images, ref_image_url)
|
||||||
local_urls.append(f"[生成失败: {e}]")
|
return GenerateResult(urls, model_name, resolved_id)
|
||||||
|
|
||||||
return local_urls
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
@@ -1,293 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from io import BytesIO
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import ExifTags, Image, ImageFile
|
|
||||||
|
|
||||||
try:
|
|
||||||
from . import _avif
|
|
||||||
|
|
||||||
SUPPORTED = True
|
|
||||||
except ImportError:
|
|
||||||
SUPPORTED = False
|
|
||||||
|
|
||||||
# Decoder options as module globals, until there is a way to pass parameters
|
|
||||||
# to Image.open (see https://github.com/python-pillow/Pillow/issues/569)
|
|
||||||
DECODE_CODEC_CHOICE = "auto"
|
|
||||||
DEFAULT_MAX_THREADS = 0
|
|
||||||
|
|
||||||
|
|
||||||
def get_codec_version(codec_name: str) -> str | None:
|
|
||||||
versions = _avif.codec_versions()
|
|
||||||
for version in versions.split(", "):
|
|
||||||
if version.split(" [")[0] == codec_name:
|
|
||||||
return version.split(":")[-1].split(" ")[0]
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool | str:
|
|
||||||
if prefix[4:8] != b"ftyp":
|
|
||||||
return False
|
|
||||||
major_brand = prefix[8:12]
|
|
||||||
if major_brand in (
|
|
||||||
# coding brands
|
|
||||||
b"avif",
|
|
||||||
b"avis",
|
|
||||||
# We accept files with AVIF container brands; we can't yet know if
|
|
||||||
# the ftyp box has the correct compatible brands, but if it doesn't
|
|
||||||
# then the plugin will raise a SyntaxError which Pillow will catch
|
|
||||||
# before moving on to the next plugin that accepts the file.
|
|
||||||
#
|
|
||||||
# Also, because this file might not actually be an AVIF file, we
|
|
||||||
# don't raise an error if AVIF support isn't properly compiled.
|
|
||||||
b"mif1",
|
|
||||||
b"msf1",
|
|
||||||
):
|
|
||||||
if not SUPPORTED:
|
|
||||||
return (
|
|
||||||
"image file could not be identified because AVIF support not installed"
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _get_default_max_threads() -> int:
|
|
||||||
if DEFAULT_MAX_THREADS:
|
|
||||||
return DEFAULT_MAX_THREADS
|
|
||||||
if hasattr(os, "sched_getaffinity"):
|
|
||||||
return len(os.sched_getaffinity(0))
|
|
||||||
else:
|
|
||||||
return os.cpu_count() or 1
|
|
||||||
|
|
||||||
|
|
||||||
class AvifImageFile(ImageFile.ImageFile):
|
|
||||||
format = "AVIF"
|
|
||||||
format_description = "AVIF image"
|
|
||||||
__frame = -1
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
if not SUPPORTED:
|
|
||||||
msg = "image file could not be opened because AVIF support not installed"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available(
|
|
||||||
DECODE_CODEC_CHOICE
|
|
||||||
):
|
|
||||||
msg = "Invalid opening codec"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
self._decoder = _avif.AvifDecoder(
|
|
||||||
self.fp.read(),
|
|
||||||
DECODE_CODEC_CHOICE,
|
|
||||||
_get_default_max_threads(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get info from decoder
|
|
||||||
self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = (
|
|
||||||
self._decoder.get_info()
|
|
||||||
)
|
|
||||||
self.is_animated = self.n_frames > 1
|
|
||||||
|
|
||||||
if icc:
|
|
||||||
self.info["icc_profile"] = icc
|
|
||||||
if xmp:
|
|
||||||
self.info["xmp"] = xmp
|
|
||||||
|
|
||||||
if exif_orientation != 1 or exif:
|
|
||||||
exif_data = Image.Exif()
|
|
||||||
if exif:
|
|
||||||
exif_data.load(exif)
|
|
||||||
original_orientation = exif_data.get(ExifTags.Base.Orientation, 1)
|
|
||||||
else:
|
|
||||||
original_orientation = 1
|
|
||||||
if exif_orientation != original_orientation:
|
|
||||||
exif_data[ExifTags.Base.Orientation] = exif_orientation
|
|
||||||
exif = exif_data.tobytes()
|
|
||||||
if exif:
|
|
||||||
self.info["exif"] = exif
|
|
||||||
self.seek(0)
|
|
||||||
|
|
||||||
def seek(self, frame: int) -> None:
|
|
||||||
if not self._seek_check(frame):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Set tile
|
|
||||||
self.__frame = frame
|
|
||||||
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
|
|
||||||
|
|
||||||
def load(self) -> Image.core.PixelAccess | None:
|
|
||||||
if self.tile:
|
|
||||||
# We need to load the image data for this frame
|
|
||||||
data, timescale, pts_in_timescales, duration_in_timescales = (
|
|
||||||
self._decoder.get_frame(self.__frame)
|
|
||||||
)
|
|
||||||
self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale))
|
|
||||||
self.info["duration"] = round(1000 * (duration_in_timescales / timescale))
|
|
||||||
|
|
||||||
if self.fp and self._exclusive_fp:
|
|
||||||
self.fp.close()
|
|
||||||
self.fp = BytesIO(data)
|
|
||||||
|
|
||||||
return super().load()
|
|
||||||
|
|
||||||
def load_seek(self, pos: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def tell(self) -> int:
|
|
||||||
return self.__frame
|
|
||||||
|
|
||||||
|
|
||||||
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
_save(im, fp, filename, save_all=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _save(
|
|
||||||
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
|
|
||||||
) -> None:
|
|
||||||
info = im.encoderinfo.copy()
|
|
||||||
if save_all:
|
|
||||||
append_images = list(info.get("append_images", []))
|
|
||||||
else:
|
|
||||||
append_images = []
|
|
||||||
|
|
||||||
total = 0
|
|
||||||
for ims in [im] + append_images:
|
|
||||||
total += getattr(ims, "n_frames", 1)
|
|
||||||
|
|
||||||
quality = info.get("quality", 75)
|
|
||||||
if not isinstance(quality, int) or quality < 0 or quality > 100:
|
|
||||||
msg = "Invalid quality setting"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
duration = info.get("duration", 0)
|
|
||||||
subsampling = info.get("subsampling", "4:2:0")
|
|
||||||
speed = info.get("speed", 6)
|
|
||||||
max_threads = info.get("max_threads", _get_default_max_threads())
|
|
||||||
codec = info.get("codec", "auto")
|
|
||||||
if codec != "auto" and not _avif.encoder_codec_available(codec):
|
|
||||||
msg = "Invalid saving codec"
|
|
||||||
raise ValueError(msg)
|
|
||||||
range_ = info.get("range", "full")
|
|
||||||
tile_rows_log2 = info.get("tile_rows", 0)
|
|
||||||
tile_cols_log2 = info.get("tile_cols", 0)
|
|
||||||
alpha_premultiplied = bool(info.get("alpha_premultiplied", False))
|
|
||||||
autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0))
|
|
||||||
|
|
||||||
icc_profile = info.get("icc_profile", im.info.get("icc_profile"))
|
|
||||||
exif_orientation = 1
|
|
||||||
if exif := info.get("exif"):
|
|
||||||
if isinstance(exif, Image.Exif):
|
|
||||||
exif_data = exif
|
|
||||||
else:
|
|
||||||
exif_data = Image.Exif()
|
|
||||||
exif_data.load(exif)
|
|
||||||
if ExifTags.Base.Orientation in exif_data:
|
|
||||||
exif_orientation = exif_data.pop(ExifTags.Base.Orientation)
|
|
||||||
exif = exif_data.tobytes() if exif_data else b""
|
|
||||||
elif isinstance(exif, Image.Exif):
|
|
||||||
exif = exif_data.tobytes()
|
|
||||||
|
|
||||||
xmp = info.get("xmp")
|
|
||||||
|
|
||||||
if isinstance(xmp, str):
|
|
||||||
xmp = xmp.encode("utf-8")
|
|
||||||
|
|
||||||
advanced = info.get("advanced")
|
|
||||||
if advanced is not None:
|
|
||||||
if isinstance(advanced, dict):
|
|
||||||
advanced = advanced.items()
|
|
||||||
try:
|
|
||||||
advanced = tuple(advanced)
|
|
||||||
except TypeError:
|
|
||||||
invalid = True
|
|
||||||
else:
|
|
||||||
invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced)
|
|
||||||
if invalid:
|
|
||||||
msg = (
|
|
||||||
"advanced codec options must be a dict of key-value string "
|
|
||||||
"pairs or a series of key-value two-tuples"
|
|
||||||
)
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
# Setup the AVIF encoder
|
|
||||||
enc = _avif.AvifEncoder(
|
|
||||||
im.size,
|
|
||||||
subsampling,
|
|
||||||
quality,
|
|
||||||
speed,
|
|
||||||
max_threads,
|
|
||||||
codec,
|
|
||||||
range_,
|
|
||||||
tile_rows_log2,
|
|
||||||
tile_cols_log2,
|
|
||||||
alpha_premultiplied,
|
|
||||||
autotiling,
|
|
||||||
icc_profile or b"",
|
|
||||||
exif or b"",
|
|
||||||
exif_orientation,
|
|
||||||
xmp or b"",
|
|
||||||
advanced,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add each frame
|
|
||||||
frame_idx = 0
|
|
||||||
frame_duration = 0
|
|
||||||
cur_idx = im.tell()
|
|
||||||
is_single_frame = total == 1
|
|
||||||
try:
|
|
||||||
for ims in [im] + append_images:
|
|
||||||
# Get number of frames in this image
|
|
||||||
nfr = getattr(ims, "n_frames", 1)
|
|
||||||
|
|
||||||
for idx in range(nfr):
|
|
||||||
ims.seek(idx)
|
|
||||||
|
|
||||||
# Make sure image mode is supported
|
|
||||||
frame = ims
|
|
||||||
rawmode = ims.mode
|
|
||||||
if ims.mode not in {"RGB", "RGBA"}:
|
|
||||||
rawmode = "RGBA" if ims.has_transparency_data else "RGB"
|
|
||||||
frame = ims.convert(rawmode)
|
|
||||||
|
|
||||||
# Update frame duration
|
|
||||||
if isinstance(duration, (list, tuple)):
|
|
||||||
frame_duration = duration[frame_idx]
|
|
||||||
else:
|
|
||||||
frame_duration = duration
|
|
||||||
|
|
||||||
# Append the frame to the animation encoder
|
|
||||||
enc.add(
|
|
||||||
frame.tobytes("raw", rawmode),
|
|
||||||
frame_duration,
|
|
||||||
frame.size,
|
|
||||||
rawmode,
|
|
||||||
is_single_frame,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update frame index
|
|
||||||
frame_idx += 1
|
|
||||||
|
|
||||||
if not save_all:
|
|
||||||
break
|
|
||||||
|
|
||||||
finally:
|
|
||||||
im.seek(cur_idx)
|
|
||||||
|
|
||||||
# Get the final output from the encoder
|
|
||||||
data = enc.finish()
|
|
||||||
if data is None:
|
|
||||||
msg = "cannot write file as AVIF (encoder returned None)"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
fp.write(data)
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(AvifImageFile.format, AvifImageFile, _accept)
|
|
||||||
if SUPPORTED:
|
|
||||||
Image.register_save(AvifImageFile.format, _save)
|
|
||||||
Image.register_save_all(AvifImageFile.format, _save_all)
|
|
||||||
Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"])
|
|
||||||
Image.register_mime(AvifImageFile.format, "image/avif")
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# bitmap distribution font (bdf) file parser
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 1996-05-16 fl created (as bdf2pil)
|
|
||||||
# 1997-08-25 fl converted to FontFile driver
|
|
||||||
# 2001-05-25 fl removed bogus __init__ call
|
|
||||||
# 2002-11-20 fl robustification (from Kevin Cazabon, Dmitry Vasiliev)
|
|
||||||
# 2003-04-22 fl more robustification (from Graham Dumpleton)
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
|
||||||
# Copyright (c) 1997-2003 by Fredrik Lundh.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
|
|
||||||
"""
|
|
||||||
Parse X Bitmap Distribution Format (BDF)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import BinaryIO
|
|
||||||
|
|
||||||
from . import FontFile, Image
|
|
||||||
|
|
||||||
|
|
||||||
def bdf_char(
|
|
||||||
f: BinaryIO,
|
|
||||||
) -> (
|
|
||||||
tuple[
|
|
||||||
str,
|
|
||||||
int,
|
|
||||||
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]],
|
|
||||||
Image.Image,
|
|
||||||
]
|
|
||||||
| None
|
|
||||||
):
|
|
||||||
# skip to STARTCHAR
|
|
||||||
while True:
|
|
||||||
s = f.readline()
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
if s.startswith(b"STARTCHAR"):
|
|
||||||
break
|
|
||||||
id = s[9:].strip().decode("ascii")
|
|
||||||
|
|
||||||
# load symbol properties
|
|
||||||
props = {}
|
|
||||||
while True:
|
|
||||||
s = f.readline()
|
|
||||||
if not s or s.startswith(b"BITMAP"):
|
|
||||||
break
|
|
||||||
i = s.find(b" ")
|
|
||||||
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
|
||||||
|
|
||||||
# load bitmap
|
|
||||||
bitmap = bytearray()
|
|
||||||
while True:
|
|
||||||
s = f.readline()
|
|
||||||
if not s or s.startswith(b"ENDCHAR"):
|
|
||||||
break
|
|
||||||
bitmap += s[:-1]
|
|
||||||
|
|
||||||
# The word BBX
|
|
||||||
# followed by the width in x (BBw), height in y (BBh),
|
|
||||||
# and x and y displacement (BBxoff0, BByoff0)
|
|
||||||
# of the lower left corner from the origin of the character.
|
|
||||||
width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split())
|
|
||||||
|
|
||||||
# The word DWIDTH
|
|
||||||
# followed by the width in x and y of the character in device pixels.
|
|
||||||
dwx, dwy = (int(p) for p in props["DWIDTH"].split())
|
|
||||||
|
|
||||||
bbox = (
|
|
||||||
(dwx, dwy),
|
|
||||||
(x_disp, -y_disp - height, width + x_disp, -y_disp),
|
|
||||||
(0, 0, width, height),
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
im = Image.frombytes("1", (width, height), bitmap, "hex", "1")
|
|
||||||
except ValueError:
|
|
||||||
# deal with zero-width characters
|
|
||||||
im = Image.new("1", (width, height))
|
|
||||||
|
|
||||||
return id, int(props["ENCODING"]), bbox, im
|
|
||||||
|
|
||||||
|
|
||||||
class BdfFontFile(FontFile.FontFile):
|
|
||||||
"""Font file plugin for the X11 BDF format."""
|
|
||||||
|
|
||||||
def __init__(self, fp: BinaryIO) -> None:
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
s = fp.readline()
|
|
||||||
if not s.startswith(b"STARTFONT 2.1"):
|
|
||||||
msg = "not a valid BDF file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
props = {}
|
|
||||||
comments = []
|
|
||||||
|
|
||||||
while True:
|
|
||||||
s = fp.readline()
|
|
||||||
if not s or s.startswith(b"ENDPROPERTIES"):
|
|
||||||
break
|
|
||||||
i = s.find(b" ")
|
|
||||||
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
|
||||||
if s[:i] in [b"COMMENT", b"COPYRIGHT"]:
|
|
||||||
if s.find(b"LogicalFontDescription") < 0:
|
|
||||||
comments.append(s[i + 1 : -1].decode("ascii"))
|
|
||||||
|
|
||||||
while True:
|
|
||||||
c = bdf_char(fp)
|
|
||||||
if not c:
|
|
||||||
break
|
|
||||||
id, ch, (xy, dst, src), im = c
|
|
||||||
if 0 <= ch < len(self.glyph):
|
|
||||||
self.glyph[ch] = xy, dst, src, im
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
"""
|
|
||||||
Blizzard Mipmap Format (.blp)
|
|
||||||
Jerome Leclanche <jerome@leclan.ch>
|
|
||||||
|
|
||||||
The contents of this file are hereby released in the public domain (CC0)
|
|
||||||
Full text of the CC0 license:
|
|
||||||
https://creativecommons.org/publicdomain/zero/1.0/
|
|
||||||
|
|
||||||
BLP1 files, used mostly in Warcraft III, are not fully supported.
|
|
||||||
All types of BLP2 files used in World of Warcraft are supported.
|
|
||||||
|
|
||||||
The BLP file structure consists of a header, up to 16 mipmaps of the
|
|
||||||
texture
|
|
||||||
|
|
||||||
Texture sizes must be powers of two, though the two dimensions do
|
|
||||||
not have to be equal; 512x256 is valid, but 512x200 is not.
|
|
||||||
The first mipmap (mipmap #0) is the full size image; each subsequent
|
|
||||||
mipmap halves both dimensions. The final mipmap should be 1x1.
|
|
||||||
|
|
||||||
BLP files come in many different flavours:
|
|
||||||
* JPEG-compressed (type == 0) - only supported for BLP1.
|
|
||||||
* RAW images (type == 1, encoding == 1). Each mipmap is stored as an
|
|
||||||
array of 8-bit values, one per pixel, left to right, top to bottom.
|
|
||||||
Each value is an index to the palette.
|
|
||||||
* DXT-compressed (type == 1, encoding == 2):
|
|
||||||
- DXT1 compression is used if alpha_encoding == 0.
|
|
||||||
- An additional alpha bit is used if alpha_depth == 1.
|
|
||||||
- DXT3 compression is used if alpha_encoding == 1.
|
|
||||||
- DXT5 compression is used if alpha_encoding == 7.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import abc
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from enum import IntEnum
|
|
||||||
from io import BytesIO
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
|
|
||||||
|
|
||||||
class Format(IntEnum):
|
|
||||||
JPEG = 0
|
|
||||||
|
|
||||||
|
|
||||||
class Encoding(IntEnum):
|
|
||||||
UNCOMPRESSED = 1
|
|
||||||
DXT = 2
|
|
||||||
UNCOMPRESSED_RAW_BGRA = 3
|
|
||||||
|
|
||||||
|
|
||||||
class AlphaEncoding(IntEnum):
|
|
||||||
DXT1 = 0
|
|
||||||
DXT3 = 1
|
|
||||||
DXT5 = 7
|
|
||||||
|
|
||||||
|
|
||||||
def unpack_565(i: int) -> tuple[int, int, int]:
|
|
||||||
return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3
|
|
||||||
|
|
||||||
|
|
||||||
def decode_dxt1(
|
|
||||||
data: bytes, alpha: bool = False
|
|
||||||
) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
|
||||||
"""
|
|
||||||
input: one "row" of data (i.e. will produce 4*width pixels)
|
|
||||||
"""
|
|
||||||
|
|
||||||
blocks = len(data) // 8 # number of blocks in row
|
|
||||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
|
||||||
|
|
||||||
for block_index in range(blocks):
|
|
||||||
# Decode next 8-byte block.
|
|
||||||
idx = block_index * 8
|
|
||||||
color0, color1, bits = struct.unpack_from("<HHI", data, idx)
|
|
||||||
|
|
||||||
r0, g0, b0 = unpack_565(color0)
|
|
||||||
r1, g1, b1 = unpack_565(color1)
|
|
||||||
|
|
||||||
# Decode this block into 4x4 pixels
|
|
||||||
# Accumulate the results onto our 4 row accumulators
|
|
||||||
for j in range(4):
|
|
||||||
for i in range(4):
|
|
||||||
# get next control op and generate a pixel
|
|
||||||
|
|
||||||
control = bits & 3
|
|
||||||
bits = bits >> 2
|
|
||||||
|
|
||||||
a = 0xFF
|
|
||||||
if control == 0:
|
|
||||||
r, g, b = r0, g0, b0
|
|
||||||
elif control == 1:
|
|
||||||
r, g, b = r1, g1, b1
|
|
||||||
elif control == 2:
|
|
||||||
if color0 > color1:
|
|
||||||
r = (2 * r0 + r1) // 3
|
|
||||||
g = (2 * g0 + g1) // 3
|
|
||||||
b = (2 * b0 + b1) // 3
|
|
||||||
else:
|
|
||||||
r = (r0 + r1) // 2
|
|
||||||
g = (g0 + g1) // 2
|
|
||||||
b = (b0 + b1) // 2
|
|
||||||
elif control == 3:
|
|
||||||
if color0 > color1:
|
|
||||||
r = (2 * r1 + r0) // 3
|
|
||||||
g = (2 * g1 + g0) // 3
|
|
||||||
b = (2 * b1 + b0) // 3
|
|
||||||
else:
|
|
||||||
r, g, b, a = 0, 0, 0, 0
|
|
||||||
|
|
||||||
if alpha:
|
|
||||||
ret[j].extend([r, g, b, a])
|
|
||||||
else:
|
|
||||||
ret[j].extend([r, g, b])
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
|
||||||
"""
|
|
||||||
input: one "row" of data (i.e. will produce 4*width pixels)
|
|
||||||
"""
|
|
||||||
|
|
||||||
blocks = len(data) // 16 # number of blocks in row
|
|
||||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
|
||||||
|
|
||||||
for block_index in range(blocks):
|
|
||||||
idx = block_index * 16
|
|
||||||
block = data[idx : idx + 16]
|
|
||||||
# Decode next 16-byte block.
|
|
||||||
bits = struct.unpack_from("<8B", block)
|
|
||||||
color0, color1 = struct.unpack_from("<HH", block, 8)
|
|
||||||
|
|
||||||
(code,) = struct.unpack_from("<I", block, 12)
|
|
||||||
|
|
||||||
r0, g0, b0 = unpack_565(color0)
|
|
||||||
r1, g1, b1 = unpack_565(color1)
|
|
||||||
|
|
||||||
for j in range(4):
|
|
||||||
high = False # Do we want the higher bits?
|
|
||||||
for i in range(4):
|
|
||||||
alphacode_index = (4 * j + i) // 2
|
|
||||||
a = bits[alphacode_index]
|
|
||||||
if high:
|
|
||||||
high = False
|
|
||||||
a >>= 4
|
|
||||||
else:
|
|
||||||
high = True
|
|
||||||
a &= 0xF
|
|
||||||
a *= 17 # We get a value between 0 and 15
|
|
||||||
|
|
||||||
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
|
||||||
|
|
||||||
if color_code == 0:
|
|
||||||
r, g, b = r0, g0, b0
|
|
||||||
elif color_code == 1:
|
|
||||||
r, g, b = r1, g1, b1
|
|
||||||
elif color_code == 2:
|
|
||||||
r = (2 * r0 + r1) // 3
|
|
||||||
g = (2 * g0 + g1) // 3
|
|
||||||
b = (2 * b0 + b1) // 3
|
|
||||||
elif color_code == 3:
|
|
||||||
r = (2 * r1 + r0) // 3
|
|
||||||
g = (2 * g1 + g0) // 3
|
|
||||||
b = (2 * b1 + b0) // 3
|
|
||||||
|
|
||||||
ret[j].extend([r, g, b, a])
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
|
||||||
"""
|
|
||||||
input: one "row" of data (i.e. will produce 4 * width pixels)
|
|
||||||
"""
|
|
||||||
|
|
||||||
blocks = len(data) // 16 # number of blocks in row
|
|
||||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
|
||||||
|
|
||||||
for block_index in range(blocks):
|
|
||||||
idx = block_index * 16
|
|
||||||
block = data[idx : idx + 16]
|
|
||||||
# Decode next 16-byte block.
|
|
||||||
a0, a1 = struct.unpack_from("<BB", block)
|
|
||||||
|
|
||||||
bits = struct.unpack_from("<6B", block, 2)
|
|
||||||
alphacode1 = bits[2] | (bits[3] << 8) | (bits[4] << 16) | (bits[5] << 24)
|
|
||||||
alphacode2 = bits[0] | (bits[1] << 8)
|
|
||||||
|
|
||||||
color0, color1 = struct.unpack_from("<HH", block, 8)
|
|
||||||
|
|
||||||
(code,) = struct.unpack_from("<I", block, 12)
|
|
||||||
|
|
||||||
r0, g0, b0 = unpack_565(color0)
|
|
||||||
r1, g1, b1 = unpack_565(color1)
|
|
||||||
|
|
||||||
for j in range(4):
|
|
||||||
for i in range(4):
|
|
||||||
# get next control op and generate a pixel
|
|
||||||
alphacode_index = 3 * (4 * j + i)
|
|
||||||
|
|
||||||
if alphacode_index <= 12:
|
|
||||||
alphacode = (alphacode2 >> alphacode_index) & 0x07
|
|
||||||
elif alphacode_index == 15:
|
|
||||||
alphacode = (alphacode2 >> 15) | ((alphacode1 << 1) & 0x06)
|
|
||||||
else: # alphacode_index >= 18 and alphacode_index <= 45
|
|
||||||
alphacode = (alphacode1 >> (alphacode_index - 16)) & 0x07
|
|
||||||
|
|
||||||
if alphacode == 0:
|
|
||||||
a = a0
|
|
||||||
elif alphacode == 1:
|
|
||||||
a = a1
|
|
||||||
elif a0 > a1:
|
|
||||||
a = ((8 - alphacode) * a0 + (alphacode - 1) * a1) // 7
|
|
||||||
elif alphacode == 6:
|
|
||||||
a = 0
|
|
||||||
elif alphacode == 7:
|
|
||||||
a = 255
|
|
||||||
else:
|
|
||||||
a = ((6 - alphacode) * a0 + (alphacode - 1) * a1) // 5
|
|
||||||
|
|
||||||
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
|
||||||
|
|
||||||
if color_code == 0:
|
|
||||||
r, g, b = r0, g0, b0
|
|
||||||
elif color_code == 1:
|
|
||||||
r, g, b = r1, g1, b1
|
|
||||||
elif color_code == 2:
|
|
||||||
r = (2 * r0 + r1) // 3
|
|
||||||
g = (2 * g0 + g1) // 3
|
|
||||||
b = (2 * b0 + b1) // 3
|
|
||||||
elif color_code == 3:
|
|
||||||
r = (2 * r1 + r0) // 3
|
|
||||||
g = (2 * g1 + g0) // 3
|
|
||||||
b = (2 * b1 + b0) // 3
|
|
||||||
|
|
||||||
ret[j].extend([r, g, b, a])
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
class BLPFormatError(NotImplementedError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith((b"BLP1", b"BLP2"))
|
|
||||||
|
|
||||||
|
|
||||||
class BlpImageFile(ImageFile.ImageFile):
|
|
||||||
"""
|
|
||||||
Blizzard Mipmap Format
|
|
||||||
"""
|
|
||||||
|
|
||||||
format = "BLP"
|
|
||||||
format_description = "Blizzard Mipmap Format"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
self.magic = self.fp.read(4)
|
|
||||||
if not _accept(self.magic):
|
|
||||||
msg = f"Bad BLP magic {repr(self.magic)}"
|
|
||||||
raise BLPFormatError(msg)
|
|
||||||
|
|
||||||
compression = struct.unpack("<i", self.fp.read(4))[0]
|
|
||||||
if self.magic == b"BLP1":
|
|
||||||
alpha = struct.unpack("<I", self.fp.read(4))[0] != 0
|
|
||||||
else:
|
|
||||||
encoding = struct.unpack("<b", self.fp.read(1))[0]
|
|
||||||
alpha = struct.unpack("<b", self.fp.read(1))[0] != 0
|
|
||||||
alpha_encoding = struct.unpack("<b", self.fp.read(1))[0]
|
|
||||||
self.fp.seek(1, os.SEEK_CUR) # mips
|
|
||||||
|
|
||||||
self._size = struct.unpack("<II", self.fp.read(8))
|
|
||||||
|
|
||||||
args: tuple[int, int, bool] | tuple[int, int, bool, int]
|
|
||||||
if self.magic == b"BLP1":
|
|
||||||
encoding = struct.unpack("<i", self.fp.read(4))[0]
|
|
||||||
self.fp.seek(4, os.SEEK_CUR) # subtype
|
|
||||||
|
|
||||||
args = (compression, encoding, alpha)
|
|
||||||
offset = 28
|
|
||||||
else:
|
|
||||||
args = (compression, encoding, alpha, alpha_encoding)
|
|
||||||
offset = 20
|
|
||||||
|
|
||||||
decoder = self.magic.decode()
|
|
||||||
|
|
||||||
self._mode = "RGBA" if alpha else "RGB"
|
|
||||||
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
|
|
||||||
|
|
||||||
|
|
||||||
class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
|
|
||||||
_pulls_fd = True
|
|
||||||
|
|
||||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
|
||||||
try:
|
|
||||||
self._read_header()
|
|
||||||
self._load()
|
|
||||||
except struct.error as e:
|
|
||||||
msg = "Truncated BLP file"
|
|
||||||
raise OSError(msg) from e
|
|
||||||
return -1, 0
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def _load(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _read_header(self) -> None:
|
|
||||||
self._offsets = struct.unpack("<16I", self._safe_read(16 * 4))
|
|
||||||
self._lengths = struct.unpack("<16I", self._safe_read(16 * 4))
|
|
||||||
|
|
||||||
def _safe_read(self, length: int) -> bytes:
|
|
||||||
assert self.fd is not None
|
|
||||||
return ImageFile._safe_read(self.fd, length)
|
|
||||||
|
|
||||||
def _read_palette(self) -> list[tuple[int, int, int, int]]:
|
|
||||||
ret = []
|
|
||||||
for i in range(256):
|
|
||||||
try:
|
|
||||||
b, g, r, a = struct.unpack("<4B", self._safe_read(4))
|
|
||||||
except struct.error:
|
|
||||||
break
|
|
||||||
ret.append((b, g, r, a))
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def _read_bgra(
|
|
||||||
self, palette: list[tuple[int, int, int, int]], alpha: bool
|
|
||||||
) -> bytearray:
|
|
||||||
data = bytearray()
|
|
||||||
_data = BytesIO(self._safe_read(self._lengths[0]))
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
(offset,) = struct.unpack("<B", _data.read(1))
|
|
||||||
except struct.error:
|
|
||||||
break
|
|
||||||
b, g, r, a = palette[offset]
|
|
||||||
d: tuple[int, ...] = (r, g, b)
|
|
||||||
if alpha:
|
|
||||||
d += (a,)
|
|
||||||
data.extend(d)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class BLP1Decoder(_BLPBaseDecoder):
|
|
||||||
def _load(self) -> None:
|
|
||||||
self._compression, self._encoding, alpha = self.args
|
|
||||||
|
|
||||||
if self._compression == Format.JPEG:
|
|
||||||
self._decode_jpeg_stream()
|
|
||||||
|
|
||||||
elif self._compression == 1:
|
|
||||||
if self._encoding in (4, 5):
|
|
||||||
palette = self._read_palette()
|
|
||||||
data = self._read_bgra(palette, alpha)
|
|
||||||
self.set_as_raw(data)
|
|
||||||
else:
|
|
||||||
msg = f"Unsupported BLP encoding {repr(self._encoding)}"
|
|
||||||
raise BLPFormatError(msg)
|
|
||||||
else:
|
|
||||||
msg = f"Unsupported BLP compression {repr(self._encoding)}"
|
|
||||||
raise BLPFormatError(msg)
|
|
||||||
|
|
||||||
def _decode_jpeg_stream(self) -> None:
|
|
||||||
from .JpegImagePlugin import JpegImageFile
|
|
||||||
|
|
||||||
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
|
|
||||||
jpeg_header = self._safe_read(jpeg_header_size)
|
|
||||||
assert self.fd is not None
|
|
||||||
self._safe_read(self._offsets[0] - self.fd.tell()) # What IS this?
|
|
||||||
data = self._safe_read(self._lengths[0])
|
|
||||||
data = jpeg_header + data
|
|
||||||
image = JpegImageFile(BytesIO(data))
|
|
||||||
Image._decompression_bomb_check(image.size)
|
|
||||||
if image.mode == "CMYK":
|
|
||||||
args = image.tile[0].args
|
|
||||||
assert isinstance(args, tuple)
|
|
||||||
image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))]
|
|
||||||
self.set_as_raw(image.convert("RGB").tobytes(), "BGR")
|
|
||||||
|
|
||||||
|
|
||||||
class BLP2Decoder(_BLPBaseDecoder):
|
|
||||||
def _load(self) -> None:
|
|
||||||
self._compression, self._encoding, alpha, self._alpha_encoding = self.args
|
|
||||||
|
|
||||||
palette = self._read_palette()
|
|
||||||
|
|
||||||
assert self.fd is not None
|
|
||||||
self.fd.seek(self._offsets[0])
|
|
||||||
|
|
||||||
if self._compression == 1:
|
|
||||||
# Uncompressed or DirectX compression
|
|
||||||
|
|
||||||
if self._encoding == Encoding.UNCOMPRESSED:
|
|
||||||
data = self._read_bgra(palette, alpha)
|
|
||||||
|
|
||||||
elif self._encoding == Encoding.DXT:
|
|
||||||
data = bytearray()
|
|
||||||
if self._alpha_encoding == AlphaEncoding.DXT1:
|
|
||||||
linesize = (self.state.xsize + 3) // 4 * 8
|
|
||||||
for yb in range((self.state.ysize + 3) // 4):
|
|
||||||
for d in decode_dxt1(self._safe_read(linesize), alpha):
|
|
||||||
data += d
|
|
||||||
|
|
||||||
elif self._alpha_encoding == AlphaEncoding.DXT3:
|
|
||||||
linesize = (self.state.xsize + 3) // 4 * 16
|
|
||||||
for yb in range((self.state.ysize + 3) // 4):
|
|
||||||
for d in decode_dxt3(self._safe_read(linesize)):
|
|
||||||
data += d
|
|
||||||
|
|
||||||
elif self._alpha_encoding == AlphaEncoding.DXT5:
|
|
||||||
linesize = (self.state.xsize + 3) // 4 * 16
|
|
||||||
for yb in range((self.state.ysize + 3) // 4):
|
|
||||||
for d in decode_dxt5(self._safe_read(linesize)):
|
|
||||||
data += d
|
|
||||||
else:
|
|
||||||
msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}"
|
|
||||||
raise BLPFormatError(msg)
|
|
||||||
else:
|
|
||||||
msg = f"Unknown BLP encoding {repr(self._encoding)}"
|
|
||||||
raise BLPFormatError(msg)
|
|
||||||
|
|
||||||
else:
|
|
||||||
msg = f"Unknown BLP compression {repr(self._compression)}"
|
|
||||||
raise BLPFormatError(msg)
|
|
||||||
|
|
||||||
self.set_as_raw(data)
|
|
||||||
|
|
||||||
|
|
||||||
class BLPEncoder(ImageFile.PyEncoder):
|
|
||||||
_pushes_fd = True
|
|
||||||
|
|
||||||
def _write_palette(self) -> bytes:
|
|
||||||
data = b""
|
|
||||||
assert self.im is not None
|
|
||||||
palette = self.im.getpalette("RGBA", "RGBA")
|
|
||||||
for i in range(len(palette) // 4):
|
|
||||||
r, g, b, a = palette[i * 4 : (i + 1) * 4]
|
|
||||||
data += struct.pack("<4B", b, g, r, a)
|
|
||||||
while len(data) < 256 * 4:
|
|
||||||
data += b"\x00" * 4
|
|
||||||
return data
|
|
||||||
|
|
||||||
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
|
|
||||||
palette_data = self._write_palette()
|
|
||||||
|
|
||||||
offset = 20 + 16 * 4 * 2 + len(palette_data)
|
|
||||||
data = struct.pack("<16I", offset, *((0,) * 15))
|
|
||||||
|
|
||||||
assert self.im is not None
|
|
||||||
w, h = self.im.size
|
|
||||||
data += struct.pack("<16I", w * h, *((0,) * 15))
|
|
||||||
|
|
||||||
data += palette_data
|
|
||||||
|
|
||||||
for y in range(h):
|
|
||||||
for x in range(w):
|
|
||||||
data += struct.pack("<B", self.im.getpixel((x, y)))
|
|
||||||
|
|
||||||
return len(data), 0, data
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
if im.mode != "P":
|
|
||||||
msg = "Unsupported BLP image mode"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2"
|
|
||||||
fp.write(magic)
|
|
||||||
|
|
||||||
assert im.palette is not None
|
|
||||||
fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression
|
|
||||||
|
|
||||||
alpha_depth = 1 if im.palette.mode == "RGBA" else 0
|
|
||||||
if magic == b"BLP1":
|
|
||||||
fp.write(struct.pack("<L", alpha_depth))
|
|
||||||
else:
|
|
||||||
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
|
|
||||||
fp.write(struct.pack("<b", alpha_depth))
|
|
||||||
fp.write(struct.pack("<b", 0)) # alpha encoding
|
|
||||||
fp.write(struct.pack("<b", 0)) # mips
|
|
||||||
fp.write(struct.pack("<II", *im.size))
|
|
||||||
if magic == b"BLP1":
|
|
||||||
fp.write(struct.pack("<i", 5))
|
|
||||||
fp.write(struct.pack("<i", 0))
|
|
||||||
|
|
||||||
ImageFile._save(im, fp, [ImageFile._Tile("BLP", (0, 0) + im.size, 0, im.mode)])
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(BlpImageFile.format, BlpImageFile, _accept)
|
|
||||||
Image.register_extension(BlpImageFile.format, ".blp")
|
|
||||||
Image.register_decoder("BLP1", BLP1Decoder)
|
|
||||||
Image.register_decoder("BLP2", BLP2Decoder)
|
|
||||||
|
|
||||||
Image.register_save(BlpImageFile.format, _save)
|
|
||||||
Image.register_encoder("BLP", BLPEncoder)
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# BMP file handler
|
|
||||||
#
|
|
||||||
# Windows (and OS/2) native bitmap storage format.
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 1995-09-01 fl Created
|
|
||||||
# 1996-04-30 fl Added save
|
|
||||||
# 1997-08-27 fl Fixed save of 1-bit images
|
|
||||||
# 1998-03-06 fl Load P images as L where possible
|
|
||||||
# 1998-07-03 fl Load P images as 1 where possible
|
|
||||||
# 1998-12-29 fl Handle small palettes
|
|
||||||
# 2002-12-30 fl Fixed load of 1-bit palette images
|
|
||||||
# 2003-04-21 fl Fixed load of 1-bit monochrome images
|
|
||||||
# 2003-04-23 fl Added limited support for BI_BITFIELDS compression
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2003 by Secret Labs AB
|
|
||||||
# Copyright (c) 1995-2003 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import IO, Any
|
|
||||||
|
|
||||||
from . import Image, ImageFile, ImagePalette
|
|
||||||
from ._binary import i16le as i16
|
|
||||||
from ._binary import i32le as i32
|
|
||||||
from ._binary import o8
|
|
||||||
from ._binary import o16le as o16
|
|
||||||
from ._binary import o32le as o32
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Read BMP file
|
|
||||||
|
|
||||||
BIT2MODE = {
|
|
||||||
# bits => mode, rawmode
|
|
||||||
1: ("P", "P;1"),
|
|
||||||
4: ("P", "P;4"),
|
|
||||||
8: ("P", "P"),
|
|
||||||
16: ("RGB", "BGR;15"),
|
|
||||||
24: ("RGB", "BGR"),
|
|
||||||
32: ("RGB", "BGRX"),
|
|
||||||
}
|
|
||||||
|
|
||||||
USE_RAW_ALPHA = False
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(b"BM")
|
|
||||||
|
|
||||||
|
|
||||||
def _dib_accept(prefix: bytes) -> bool:
|
|
||||||
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Image plugin for the Windows BMP format.
|
|
||||||
# =============================================================================
|
|
||||||
class BmpImageFile(ImageFile.ImageFile):
|
|
||||||
"""Image plugin for the Windows Bitmap format (BMP)"""
|
|
||||||
|
|
||||||
# ------------------------------------------------------------- Description
|
|
||||||
format_description = "Windows Bitmap"
|
|
||||||
format = "BMP"
|
|
||||||
|
|
||||||
# -------------------------------------------------- BMP Compression values
|
|
||||||
COMPRESSIONS = {"RAW": 0, "RLE8": 1, "RLE4": 2, "BITFIELDS": 3, "JPEG": 4, "PNG": 5}
|
|
||||||
for k, v in COMPRESSIONS.items():
|
|
||||||
vars()[k] = v
|
|
||||||
|
|
||||||
def _bitmap(self, header: int = 0, offset: int = 0) -> None:
|
|
||||||
"""Read relevant info about the BMP"""
|
|
||||||
assert self.fp is not None
|
|
||||||
read, seek = self.fp.read, self.fp.seek
|
|
||||||
if header:
|
|
||||||
seek(header)
|
|
||||||
# read bmp header size @offset 14 (this is part of the header size)
|
|
||||||
file_info: dict[str, bool | int | tuple[int, ...]] = {
|
|
||||||
"header_size": i32(read(4)),
|
|
||||||
"direction": -1,
|
|
||||||
}
|
|
||||||
|
|
||||||
# -------------------- If requested, read header at a specific position
|
|
||||||
# read the rest of the bmp header, without its size
|
|
||||||
assert isinstance(file_info["header_size"], int)
|
|
||||||
header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4)
|
|
||||||
|
|
||||||
# ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1
|
|
||||||
# ----- This format has different offsets because of width/height types
|
|
||||||
# 12: BITMAPCOREHEADER/OS21XBITMAPHEADER
|
|
||||||
if file_info["header_size"] == 12:
|
|
||||||
file_info["width"] = i16(header_data, 0)
|
|
||||||
file_info["height"] = i16(header_data, 2)
|
|
||||||
file_info["planes"] = i16(header_data, 4)
|
|
||||||
file_info["bits"] = i16(header_data, 6)
|
|
||||||
file_info["compression"] = self.COMPRESSIONS["RAW"]
|
|
||||||
file_info["palette_padding"] = 3
|
|
||||||
|
|
||||||
# --------------------------------------------- Windows Bitmap v3 to v5
|
|
||||||
# 40: BITMAPINFOHEADER
|
|
||||||
# 52: BITMAPV2HEADER
|
|
||||||
# 56: BITMAPV3HEADER
|
|
||||||
# 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER
|
|
||||||
# 108: BITMAPV4HEADER
|
|
||||||
# 124: BITMAPV5HEADER
|
|
||||||
elif file_info["header_size"] in (40, 52, 56, 64, 108, 124):
|
|
||||||
file_info["y_flip"] = header_data[7] == 0xFF
|
|
||||||
file_info["direction"] = 1 if file_info["y_flip"] else -1
|
|
||||||
file_info["width"] = i32(header_data, 0)
|
|
||||||
file_info["height"] = (
|
|
||||||
i32(header_data, 4)
|
|
||||||
if not file_info["y_flip"]
|
|
||||||
else 2**32 - i32(header_data, 4)
|
|
||||||
)
|
|
||||||
file_info["planes"] = i16(header_data, 8)
|
|
||||||
file_info["bits"] = i16(header_data, 10)
|
|
||||||
file_info["compression"] = i32(header_data, 12)
|
|
||||||
# byte size of pixel data
|
|
||||||
file_info["data_size"] = i32(header_data, 16)
|
|
||||||
file_info["pixels_per_meter"] = (
|
|
||||||
i32(header_data, 20),
|
|
||||||
i32(header_data, 24),
|
|
||||||
)
|
|
||||||
file_info["colors"] = i32(header_data, 28)
|
|
||||||
file_info["palette_padding"] = 4
|
|
||||||
assert isinstance(file_info["pixels_per_meter"], tuple)
|
|
||||||
self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"])
|
|
||||||
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
|
||||||
masks = ["r_mask", "g_mask", "b_mask"]
|
|
||||||
if len(header_data) >= 48:
|
|
||||||
if len(header_data) >= 52:
|
|
||||||
masks.append("a_mask")
|
|
||||||
else:
|
|
||||||
file_info["a_mask"] = 0x0
|
|
||||||
for idx, mask in enumerate(masks):
|
|
||||||
file_info[mask] = i32(header_data, 36 + idx * 4)
|
|
||||||
else:
|
|
||||||
# 40 byte headers only have the three components in the
|
|
||||||
# bitfields masks, ref:
|
|
||||||
# https://msdn.microsoft.com/en-us/library/windows/desktop/dd183376(v=vs.85).aspx
|
|
||||||
# See also
|
|
||||||
# https://github.com/python-pillow/Pillow/issues/1293
|
|
||||||
# There is a 4th component in the RGBQuad, in the alpha
|
|
||||||
# location, but it is listed as a reserved component,
|
|
||||||
# and it is not generally an alpha channel
|
|
||||||
file_info["a_mask"] = 0x0
|
|
||||||
for mask in masks:
|
|
||||||
file_info[mask] = i32(read(4))
|
|
||||||
assert isinstance(file_info["r_mask"], int)
|
|
||||||
assert isinstance(file_info["g_mask"], int)
|
|
||||||
assert isinstance(file_info["b_mask"], int)
|
|
||||||
assert isinstance(file_info["a_mask"], int)
|
|
||||||
file_info["rgb_mask"] = (
|
|
||||||
file_info["r_mask"],
|
|
||||||
file_info["g_mask"],
|
|
||||||
file_info["b_mask"],
|
|
||||||
)
|
|
||||||
file_info["rgba_mask"] = (
|
|
||||||
file_info["r_mask"],
|
|
||||||
file_info["g_mask"],
|
|
||||||
file_info["b_mask"],
|
|
||||||
file_info["a_mask"],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg = f"Unsupported BMP header type ({file_info['header_size']})"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
# ------------------ Special case : header is reported 40, which
|
|
||||||
# ---------------------- is shorter than real size for bpp >= 16
|
|
||||||
assert isinstance(file_info["width"], int)
|
|
||||||
assert isinstance(file_info["height"], int)
|
|
||||||
self._size = file_info["width"], file_info["height"]
|
|
||||||
|
|
||||||
# ------- If color count was not found in the header, compute from bits
|
|
||||||
assert isinstance(file_info["bits"], int)
|
|
||||||
if not file_info.get("colors", 0):
|
|
||||||
file_info["colors"] = 1 << file_info["bits"]
|
|
||||||
assert isinstance(file_info["palette_padding"], int)
|
|
||||||
assert isinstance(file_info["colors"], int)
|
|
||||||
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
|
|
||||||
offset += file_info["palette_padding"] * file_info["colors"]
|
|
||||||
|
|
||||||
# ---------------------- Check bit depth for unusual unsupported values
|
|
||||||
self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", ""))
|
|
||||||
if not self.mode:
|
|
||||||
msg = f"Unsupported BMP pixel depth ({file_info['bits']})"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
# ---------------- Process BMP with Bitfields compression (not palette)
|
|
||||||
decoder_name = "raw"
|
|
||||||
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
|
||||||
SUPPORTED: dict[int, list[tuple[int, ...]]] = {
|
|
||||||
32: [
|
|
||||||
(0xFF0000, 0xFF00, 0xFF, 0x0),
|
|
||||||
(0xFF000000, 0xFF0000, 0xFF00, 0x0),
|
|
||||||
(0xFF000000, 0xFF00, 0xFF, 0x0),
|
|
||||||
(0xFF000000, 0xFF0000, 0xFF00, 0xFF),
|
|
||||||
(0xFF, 0xFF00, 0xFF0000, 0xFF000000),
|
|
||||||
(0xFF0000, 0xFF00, 0xFF, 0xFF000000),
|
|
||||||
(0xFF000000, 0xFF00, 0xFF, 0xFF0000),
|
|
||||||
(0x0, 0x0, 0x0, 0x0),
|
|
||||||
],
|
|
||||||
24: [(0xFF0000, 0xFF00, 0xFF)],
|
|
||||||
16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)],
|
|
||||||
}
|
|
||||||
MASK_MODES = {
|
|
||||||
(32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX",
|
|
||||||
(32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR",
|
|
||||||
(32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR",
|
|
||||||
(32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR",
|
|
||||||
(32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA",
|
|
||||||
(32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA",
|
|
||||||
(32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR",
|
|
||||||
(32, (0x0, 0x0, 0x0, 0x0)): "BGRA",
|
|
||||||
(24, (0xFF0000, 0xFF00, 0xFF)): "BGR",
|
|
||||||
(16, (0xF800, 0x7E0, 0x1F)): "BGR;16",
|
|
||||||
(16, (0x7C00, 0x3E0, 0x1F)): "BGR;15",
|
|
||||||
}
|
|
||||||
if file_info["bits"] in SUPPORTED:
|
|
||||||
if (
|
|
||||||
file_info["bits"] == 32
|
|
||||||
and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]]
|
|
||||||
):
|
|
||||||
assert isinstance(file_info["rgba_mask"], tuple)
|
|
||||||
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])]
|
|
||||||
self._mode = "RGBA" if "A" in raw_mode else self.mode
|
|
||||||
elif (
|
|
||||||
file_info["bits"] in (24, 16)
|
|
||||||
and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]]
|
|
||||||
):
|
|
||||||
assert isinstance(file_info["rgb_mask"], tuple)
|
|
||||||
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])]
|
|
||||||
else:
|
|
||||||
msg = "Unsupported BMP bitfields layout"
|
|
||||||
raise OSError(msg)
|
|
||||||
else:
|
|
||||||
msg = "Unsupported BMP bitfields layout"
|
|
||||||
raise OSError(msg)
|
|
||||||
elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
|
|
||||||
if file_info["bits"] == 32 and (
|
|
||||||
header == 22 or USE_RAW_ALPHA # 32-bit .cur offset
|
|
||||||
):
|
|
||||||
raw_mode, self._mode = "BGRA", "RGBA"
|
|
||||||
elif file_info["compression"] in (
|
|
||||||
self.COMPRESSIONS["RLE8"],
|
|
||||||
self.COMPRESSIONS["RLE4"],
|
|
||||||
):
|
|
||||||
decoder_name = "bmp_rle"
|
|
||||||
else:
|
|
||||||
msg = f"Unsupported BMP compression ({file_info['compression']})"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
# --------------- Once the header is processed, process the palette/LUT
|
|
||||||
if self.mode == "P": # Paletted for 1, 4 and 8 bit images
|
|
||||||
# ---------------------------------------------------- 1-bit images
|
|
||||||
if not (0 < file_info["colors"] <= 65536):
|
|
||||||
msg = f"Unsupported BMP Palette size ({file_info['colors']})"
|
|
||||||
raise OSError(msg)
|
|
||||||
else:
|
|
||||||
padding = file_info["palette_padding"]
|
|
||||||
palette = read(padding * file_info["colors"])
|
|
||||||
grayscale = True
|
|
||||||
indices = (
|
|
||||||
(0, 255)
|
|
||||||
if file_info["colors"] == 2
|
|
||||||
else list(range(file_info["colors"]))
|
|
||||||
)
|
|
||||||
|
|
||||||
# ----------------- Check if grayscale and ignore palette if so
|
|
||||||
for ind, val in enumerate(indices):
|
|
||||||
rgb = palette[ind * padding : ind * padding + 3]
|
|
||||||
if rgb != o8(val) * 3:
|
|
||||||
grayscale = False
|
|
||||||
|
|
||||||
# ------- If all colors are gray, white or black, ditch palette
|
|
||||||
if grayscale:
|
|
||||||
self._mode = "1" if file_info["colors"] == 2 else "L"
|
|
||||||
raw_mode = self.mode
|
|
||||||
else:
|
|
||||||
self._mode = "P"
|
|
||||||
self.palette = ImagePalette.raw(
|
|
||||||
"BGRX" if padding == 4 else "BGR", palette
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------- Finally set the tile data for the plugin
|
|
||||||
self.info["compression"] = file_info["compression"]
|
|
||||||
args: list[Any] = [raw_mode]
|
|
||||||
if decoder_name == "bmp_rle":
|
|
||||||
args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"])
|
|
||||||
else:
|
|
||||||
assert isinstance(file_info["width"], int)
|
|
||||||
args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3))
|
|
||||||
args.append(file_info["direction"])
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile(
|
|
||||||
decoder_name,
|
|
||||||
(0, 0, file_info["width"], file_info["height"]),
|
|
||||||
offset or self.fp.tell(),
|
|
||||||
tuple(args),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
"""Open file, check magic number and read header"""
|
|
||||||
# read 14 bytes: magic number, filesize, reserved, header final offset
|
|
||||||
assert self.fp is not None
|
|
||||||
head_data = self.fp.read(14)
|
|
||||||
# choke if the file does not have the required magic bytes
|
|
||||||
if not _accept(head_data):
|
|
||||||
msg = "Not a BMP file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
# read the start position of the BMP image data (u32)
|
|
||||||
offset = i32(head_data, 10)
|
|
||||||
# load bitmap information (offset=raster info)
|
|
||||||
self._bitmap(offset=offset)
|
|
||||||
|
|
||||||
|
|
||||||
class BmpRleDecoder(ImageFile.PyDecoder):
|
|
||||||
_pulls_fd = True
|
|
||||||
|
|
||||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
|
||||||
assert self.fd is not None
|
|
||||||
rle4 = self.args[1]
|
|
||||||
data = bytearray()
|
|
||||||
x = 0
|
|
||||||
dest_length = self.state.xsize * self.state.ysize
|
|
||||||
while len(data) < dest_length:
|
|
||||||
pixels = self.fd.read(1)
|
|
||||||
byte = self.fd.read(1)
|
|
||||||
if not pixels or not byte:
|
|
||||||
break
|
|
||||||
num_pixels = pixels[0]
|
|
||||||
if num_pixels:
|
|
||||||
# encoded mode
|
|
||||||
if x + num_pixels > self.state.xsize:
|
|
||||||
# Too much data for row
|
|
||||||
num_pixels = max(0, self.state.xsize - x)
|
|
||||||
if rle4:
|
|
||||||
first_pixel = o8(byte[0] >> 4)
|
|
||||||
second_pixel = o8(byte[0] & 0x0F)
|
|
||||||
for index in range(num_pixels):
|
|
||||||
if index % 2 == 0:
|
|
||||||
data += first_pixel
|
|
||||||
else:
|
|
||||||
data += second_pixel
|
|
||||||
else:
|
|
||||||
data += byte * num_pixels
|
|
||||||
x += num_pixels
|
|
||||||
else:
|
|
||||||
if byte[0] == 0:
|
|
||||||
# end of line
|
|
||||||
while len(data) % self.state.xsize != 0:
|
|
||||||
data += b"\x00"
|
|
||||||
x = 0
|
|
||||||
elif byte[0] == 1:
|
|
||||||
# end of bitmap
|
|
||||||
break
|
|
||||||
elif byte[0] == 2:
|
|
||||||
# delta
|
|
||||||
bytes_read = self.fd.read(2)
|
|
||||||
if len(bytes_read) < 2:
|
|
||||||
break
|
|
||||||
right, up = bytes_read
|
|
||||||
data += b"\x00" * (right + up * self.state.xsize)
|
|
||||||
x = len(data) % self.state.xsize
|
|
||||||
else:
|
|
||||||
# absolute mode
|
|
||||||
if rle4:
|
|
||||||
# 2 pixels per byte
|
|
||||||
byte_count = byte[0] // 2
|
|
||||||
bytes_read = self.fd.read(byte_count)
|
|
||||||
for byte_read in bytes_read:
|
|
||||||
data += o8(byte_read >> 4)
|
|
||||||
data += o8(byte_read & 0x0F)
|
|
||||||
else:
|
|
||||||
byte_count = byte[0]
|
|
||||||
bytes_read = self.fd.read(byte_count)
|
|
||||||
data += bytes_read
|
|
||||||
if len(bytes_read) < byte_count:
|
|
||||||
break
|
|
||||||
x += byte[0]
|
|
||||||
|
|
||||||
# align to 16-bit word boundary
|
|
||||||
if self.fd.tell() % 2 != 0:
|
|
||||||
self.fd.seek(1, os.SEEK_CUR)
|
|
||||||
rawmode = "L" if self.mode == "L" else "P"
|
|
||||||
self.set_as_raw(bytes(data), rawmode, (0, self.args[-1]))
|
|
||||||
return -1, 0
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Image plugin for the DIB format (BMP alias)
|
|
||||||
# =============================================================================
|
|
||||||
class DibImageFile(BmpImageFile):
|
|
||||||
format = "DIB"
|
|
||||||
format_description = "Windows Bitmap"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
self._bitmap()
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Write BMP file
|
|
||||||
|
|
||||||
|
|
||||||
SAVE = {
|
|
||||||
"1": ("1", 1, 2),
|
|
||||||
"L": ("L", 8, 256),
|
|
||||||
"P": ("P", 8, 256),
|
|
||||||
"RGB": ("BGR", 24, 0),
|
|
||||||
"RGBA": ("BGRA", 32, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
_save(im, fp, filename, False)
|
|
||||||
|
|
||||||
|
|
||||||
def _save(
|
|
||||||
im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
rawmode, bits, colors = SAVE[im.mode]
|
|
||||||
except KeyError as e:
|
|
||||||
msg = f"cannot write mode {im.mode} as BMP"
|
|
||||||
raise OSError(msg) from e
|
|
||||||
|
|
||||||
info = im.encoderinfo
|
|
||||||
|
|
||||||
dpi = info.get("dpi", (96, 96))
|
|
||||||
|
|
||||||
# 1 meter == 39.3701 inches
|
|
||||||
ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi)
|
|
||||||
|
|
||||||
stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3)
|
|
||||||
header = 40 # or 64 for OS/2 version 2
|
|
||||||
image = stride * im.size[1]
|
|
||||||
|
|
||||||
if im.mode == "1":
|
|
||||||
palette = b"".join(o8(i) * 3 + b"\x00" for i in (0, 255))
|
|
||||||
elif im.mode == "L":
|
|
||||||
palette = b"".join(o8(i) * 3 + b"\x00" for i in range(256))
|
|
||||||
elif im.mode == "P":
|
|
||||||
palette = im.im.getpalette("RGB", "BGRX")
|
|
||||||
colors = len(palette) // 4
|
|
||||||
else:
|
|
||||||
palette = None
|
|
||||||
|
|
||||||
# bitmap header
|
|
||||||
if bitmap_header:
|
|
||||||
offset = 14 + header + colors * 4
|
|
||||||
file_size = offset + image
|
|
||||||
if file_size > 2**32 - 1:
|
|
||||||
msg = "File size is too large for the BMP format"
|
|
||||||
raise ValueError(msg)
|
|
||||||
fp.write(
|
|
||||||
b"BM" # file type (magic)
|
|
||||||
+ o32(file_size) # file size
|
|
||||||
+ o32(0) # reserved
|
|
||||||
+ o32(offset) # image data offset
|
|
||||||
)
|
|
||||||
|
|
||||||
# bitmap info header
|
|
||||||
fp.write(
|
|
||||||
o32(header) # info header size
|
|
||||||
+ o32(im.size[0]) # width
|
|
||||||
+ o32(im.size[1]) # height
|
|
||||||
+ o16(1) # planes
|
|
||||||
+ o16(bits) # depth
|
|
||||||
+ o32(0) # compression (0=uncompressed)
|
|
||||||
+ o32(image) # size of bitmap
|
|
||||||
+ o32(ppm[0]) # resolution
|
|
||||||
+ o32(ppm[1]) # resolution
|
|
||||||
+ o32(colors) # colors used
|
|
||||||
+ o32(colors) # colors important
|
|
||||||
)
|
|
||||||
|
|
||||||
fp.write(b"\0" * (header - 40)) # padding (for OS/2 format)
|
|
||||||
|
|
||||||
if palette:
|
|
||||||
fp.write(palette)
|
|
||||||
|
|
||||||
ImageFile._save(
|
|
||||||
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Registry
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(BmpImageFile.format, BmpImageFile, _accept)
|
|
||||||
Image.register_save(BmpImageFile.format, _save)
|
|
||||||
|
|
||||||
Image.register_extension(BmpImageFile.format, ".bmp")
|
|
||||||
|
|
||||||
Image.register_mime(BmpImageFile.format, "image/bmp")
|
|
||||||
|
|
||||||
Image.register_decoder("bmp_rle", BmpRleDecoder)
|
|
||||||
|
|
||||||
Image.register_open(DibImageFile.format, DibImageFile, _dib_accept)
|
|
||||||
Image.register_save(DibImageFile.format, _dib_save)
|
|
||||||
|
|
||||||
Image.register_extension(DibImageFile.format, ".dib")
|
|
||||||
|
|
||||||
Image.register_mime(DibImageFile.format, "image/bmp")
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# BUFR stub adapter
|
|
||||||
#
|
|
||||||
# Copyright (c) 1996-2003 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
|
|
||||||
_handler = None
|
|
||||||
|
|
||||||
|
|
||||||
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
|
||||||
"""
|
|
||||||
Install application-specific BUFR image handler.
|
|
||||||
|
|
||||||
:param handler: Handler object.
|
|
||||||
"""
|
|
||||||
global _handler
|
|
||||||
_handler = handler
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Image adapter
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith((b"BUFR", b"ZCZC"))
|
|
||||||
|
|
||||||
|
|
||||||
class BufrStubImageFile(ImageFile.StubImageFile):
|
|
||||||
format = "BUFR"
|
|
||||||
format_description = "BUFR"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
if not _accept(self.fp.read(4)):
|
|
||||||
msg = "Not a BUFR file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self.fp.seek(-4, os.SEEK_CUR)
|
|
||||||
|
|
||||||
# make something up
|
|
||||||
self._mode = "F"
|
|
||||||
self._size = 1, 1
|
|
||||||
|
|
||||||
def _load(self) -> ImageFile.StubHandler | None:
|
|
||||||
return _handler
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
if _handler is None or not hasattr(_handler, "save"):
|
|
||||||
msg = "BUFR save handler not installed"
|
|
||||||
raise OSError(msg)
|
|
||||||
_handler.save(im, fp, filename)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Registry
|
|
||||||
|
|
||||||
Image.register_open(BufrStubImageFile.format, BufrStubImageFile, _accept)
|
|
||||||
Image.register_save(BufrStubImageFile.format, _save)
|
|
||||||
|
|
||||||
Image.register_extension(BufrStubImageFile.format, ".bufr")
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# a class to read from a container file
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1995-06-18 fl Created
|
|
||||||
# 1995-09-07 fl Added readline(), readlines()
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2001 by Secret Labs AB
|
|
||||||
# Copyright (c) 1995 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from typing import IO, AnyStr, NoReturn
|
|
||||||
|
|
||||||
|
|
||||||
class ContainerIO(IO[AnyStr]):
|
|
||||||
"""
|
|
||||||
A file object that provides read access to a part of an existing
|
|
||||||
file (for example a TAR file).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None:
|
|
||||||
"""
|
|
||||||
Create file object.
|
|
||||||
|
|
||||||
:param file: Existing file.
|
|
||||||
:param offset: Start of region, in bytes.
|
|
||||||
:param length: Size of region, in bytes.
|
|
||||||
"""
|
|
||||||
self.fh: IO[AnyStr] = file
|
|
||||||
self.pos = 0
|
|
||||||
self.offset = offset
|
|
||||||
self.length = length
|
|
||||||
self.fh.seek(offset)
|
|
||||||
|
|
||||||
##
|
|
||||||
# Always false.
|
|
||||||
|
|
||||||
def isatty(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def seekable(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def seek(self, offset: int, mode: int = io.SEEK_SET) -> int:
|
|
||||||
"""
|
|
||||||
Move file pointer.
|
|
||||||
|
|
||||||
:param offset: Offset in bytes.
|
|
||||||
:param mode: Starting position. Use 0 for beginning of region, 1
|
|
||||||
for current offset, and 2 for end of region. You cannot move
|
|
||||||
the pointer outside the defined region.
|
|
||||||
:returns: Offset from start of region, in bytes.
|
|
||||||
"""
|
|
||||||
if mode == 1:
|
|
||||||
self.pos = self.pos + offset
|
|
||||||
elif mode == 2:
|
|
||||||
self.pos = self.length + offset
|
|
||||||
else:
|
|
||||||
self.pos = offset
|
|
||||||
# clamp
|
|
||||||
self.pos = max(0, min(self.pos, self.length))
|
|
||||||
self.fh.seek(self.offset + self.pos)
|
|
||||||
return self.pos
|
|
||||||
|
|
||||||
def tell(self) -> int:
|
|
||||||
"""
|
|
||||||
Get current file pointer.
|
|
||||||
|
|
||||||
:returns: Offset from start of region, in bytes.
|
|
||||||
"""
|
|
||||||
return self.pos
|
|
||||||
|
|
||||||
def readable(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def read(self, n: int = -1) -> AnyStr:
|
|
||||||
"""
|
|
||||||
Read data.
|
|
||||||
|
|
||||||
:param n: Number of bytes to read. If omitted, zero or negative,
|
|
||||||
read until end of region.
|
|
||||||
:returns: An 8-bit string.
|
|
||||||
"""
|
|
||||||
if n > 0:
|
|
||||||
n = min(n, self.length - self.pos)
|
|
||||||
else:
|
|
||||||
n = self.length - self.pos
|
|
||||||
if n <= 0: # EOF
|
|
||||||
return b"" if "b" in self.fh.mode else "" # type: ignore[return-value]
|
|
||||||
self.pos = self.pos + n
|
|
||||||
return self.fh.read(n)
|
|
||||||
|
|
||||||
def readline(self, n: int = -1) -> AnyStr:
|
|
||||||
"""
|
|
||||||
Read a line of text.
|
|
||||||
|
|
||||||
:param n: Number of bytes to read. If omitted, zero or negative,
|
|
||||||
read until end of line.
|
|
||||||
:returns: An 8-bit string.
|
|
||||||
"""
|
|
||||||
s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment]
|
|
||||||
newline_character = b"\n" if "b" in self.fh.mode else "\n"
|
|
||||||
while True:
|
|
||||||
c = self.read(1)
|
|
||||||
if not c:
|
|
||||||
break
|
|
||||||
s = s + c
|
|
||||||
if c == newline_character or len(s) == n:
|
|
||||||
break
|
|
||||||
return s
|
|
||||||
|
|
||||||
def readlines(self, n: int | None = -1) -> list[AnyStr]:
|
|
||||||
"""
|
|
||||||
Read multiple lines of text.
|
|
||||||
|
|
||||||
:param n: Number of lines to read. If omitted, zero, negative or None,
|
|
||||||
read until end of region.
|
|
||||||
:returns: A list of 8-bit strings.
|
|
||||||
"""
|
|
||||||
lines = []
|
|
||||||
while True:
|
|
||||||
s = self.readline()
|
|
||||||
if not s:
|
|
||||||
break
|
|
||||||
lines.append(s)
|
|
||||||
if len(lines) == n:
|
|
||||||
break
|
|
||||||
return lines
|
|
||||||
|
|
||||||
def writable(self) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def write(self, b: AnyStr) -> NoReturn:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def writelines(self, lines: Iterable[AnyStr]) -> NoReturn:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def truncate(self, size: int | None = None) -> int:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def __enter__(self) -> ContainerIO[AnyStr]:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args: object) -> None:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def __iter__(self) -> ContainerIO[AnyStr]:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __next__(self) -> AnyStr:
|
|
||||||
line = self.readline()
|
|
||||||
if not line:
|
|
||||||
msg = "end of region"
|
|
||||||
raise StopIteration(msg)
|
|
||||||
return line
|
|
||||||
|
|
||||||
def fileno(self) -> int:
|
|
||||||
return self.fh.fileno()
|
|
||||||
|
|
||||||
def flush(self) -> None:
|
|
||||||
self.fh.flush()
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.fh.close()
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# Windows Cursor support for PIL
|
|
||||||
#
|
|
||||||
# notes:
|
|
||||||
# uses BmpImagePlugin.py to read the bitmap data.
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 96-05-27 fl Created
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1996.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from . import BmpImagePlugin, Image
|
|
||||||
from ._binary import i16le as i16
|
|
||||||
from ._binary import i32le as i32
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(b"\0\0\2\0")
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for Windows Cursor files.
|
|
||||||
|
|
||||||
|
|
||||||
class CurImageFile(BmpImagePlugin.BmpImageFile):
|
|
||||||
format = "CUR"
|
|
||||||
format_description = "Windows Cursor"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
offset = self.fp.tell()
|
|
||||||
|
|
||||||
# check magic
|
|
||||||
s = self.fp.read(6)
|
|
||||||
if not _accept(s):
|
|
||||||
msg = "not a CUR file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
# pick the largest cursor in the file
|
|
||||||
m = b""
|
|
||||||
for i in range(i16(s, 4)):
|
|
||||||
s = self.fp.read(16)
|
|
||||||
if not m:
|
|
||||||
m = s
|
|
||||||
elif s[0] > m[0] and s[1] > m[1]:
|
|
||||||
m = s
|
|
||||||
if not m:
|
|
||||||
msg = "No cursors were found"
|
|
||||||
raise TypeError(msg)
|
|
||||||
|
|
||||||
# load as bitmap
|
|
||||||
self._bitmap(i32(m, 12) + offset)
|
|
||||||
|
|
||||||
# patch up the bitmap height
|
|
||||||
self._size = self.size[0], self.size[1] // 2
|
|
||||||
self.tile = [self.tile[0]._replace(extents=(0, 0) + self.size)]
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
Image.register_open(CurImageFile.format, CurImageFile, _accept)
|
|
||||||
|
|
||||||
Image.register_extension(CurImageFile.format, ".cur")
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# DCX file handling
|
|
||||||
#
|
|
||||||
# DCX is a container file format defined by Intel, commonly used
|
|
||||||
# for fax applications. Each DCX file consists of a directory
|
|
||||||
# (a list of file offsets) followed by a set of (usually 1-bit)
|
|
||||||
# PCX files.
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1995-09-09 fl Created
|
|
||||||
# 1996-03-20 fl Properly derived from PcxImageFile.
|
|
||||||
# 1998-07-15 fl Renamed offset attribute to avoid name clash
|
|
||||||
# 2002-07-30 fl Fixed file handling
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-98 by Secret Labs AB.
|
|
||||||
# Copyright (c) 1995-96 by Fredrik Lundh.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
from ._binary import i32le as i32
|
|
||||||
from ._util import DeferredError
|
|
||||||
from .PcxImagePlugin import PcxImageFile
|
|
||||||
|
|
||||||
MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then?
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return len(prefix) >= 4 and i32(prefix) == MAGIC
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for the Intel DCX format.
|
|
||||||
|
|
||||||
|
|
||||||
class DcxImageFile(PcxImageFile):
|
|
||||||
format = "DCX"
|
|
||||||
format_description = "Intel DCX"
|
|
||||||
_close_exclusive_fp_after_loading = False
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
# Header
|
|
||||||
assert self.fp is not None
|
|
||||||
s = self.fp.read(4)
|
|
||||||
if not _accept(s):
|
|
||||||
msg = "not a DCX file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
# Component directory
|
|
||||||
self._offset = []
|
|
||||||
for i in range(1024):
|
|
||||||
offset = i32(self.fp.read(4))
|
|
||||||
if not offset:
|
|
||||||
break
|
|
||||||
self._offset.append(offset)
|
|
||||||
|
|
||||||
self._fp = self.fp
|
|
||||||
self.frame = -1
|
|
||||||
self.n_frames = len(self._offset)
|
|
||||||
self.is_animated = self.n_frames > 1
|
|
||||||
self.seek(0)
|
|
||||||
|
|
||||||
def seek(self, frame: int) -> None:
|
|
||||||
if not self._seek_check(frame):
|
|
||||||
return
|
|
||||||
if isinstance(self._fp, DeferredError):
|
|
||||||
raise self._fp.ex
|
|
||||||
self.frame = frame
|
|
||||||
self.fp = self._fp
|
|
||||||
self.fp.seek(self._offset[frame])
|
|
||||||
PcxImageFile._open(self)
|
|
||||||
|
|
||||||
def tell(self) -> int:
|
|
||||||
return self.frame
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(DcxImageFile.format, DcxImageFile, _accept)
|
|
||||||
|
|
||||||
Image.register_extension(DcxImageFile.format, ".dcx")
|
|
||||||
@@ -1,625 +0,0 @@
|
|||||||
"""
|
|
||||||
A Pillow plugin for .dds files (S3TC-compressed aka DXTC)
|
|
||||||
Jerome Leclanche <jerome@leclan.ch>
|
|
||||||
|
|
||||||
Documentation:
|
|
||||||
https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt
|
|
||||||
|
|
||||||
The contents of this file are hereby released in the public domain (CC0)
|
|
||||||
Full text of the CC0 license:
|
|
||||||
https://creativecommons.org/publicdomain/zero/1.0/
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
from enum import IntEnum, IntFlag
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import Image, ImageFile, ImagePalette
|
|
||||||
from ._binary import i32le as i32
|
|
||||||
from ._binary import o8
|
|
||||||
from ._binary import o32le as o32
|
|
||||||
|
|
||||||
# Magic ("DDS ")
|
|
||||||
DDS_MAGIC = 0x20534444
|
|
||||||
|
|
||||||
|
|
||||||
# DDS flags
|
|
||||||
class DDSD(IntFlag):
|
|
||||||
CAPS = 0x1
|
|
||||||
HEIGHT = 0x2
|
|
||||||
WIDTH = 0x4
|
|
||||||
PITCH = 0x8
|
|
||||||
PIXELFORMAT = 0x1000
|
|
||||||
MIPMAPCOUNT = 0x20000
|
|
||||||
LINEARSIZE = 0x80000
|
|
||||||
DEPTH = 0x800000
|
|
||||||
|
|
||||||
|
|
||||||
# DDS caps
|
|
||||||
class DDSCAPS(IntFlag):
|
|
||||||
COMPLEX = 0x8
|
|
||||||
TEXTURE = 0x1000
|
|
||||||
MIPMAP = 0x400000
|
|
||||||
|
|
||||||
|
|
||||||
class DDSCAPS2(IntFlag):
|
|
||||||
CUBEMAP = 0x200
|
|
||||||
CUBEMAP_POSITIVEX = 0x400
|
|
||||||
CUBEMAP_NEGATIVEX = 0x800
|
|
||||||
CUBEMAP_POSITIVEY = 0x1000
|
|
||||||
CUBEMAP_NEGATIVEY = 0x2000
|
|
||||||
CUBEMAP_POSITIVEZ = 0x4000
|
|
||||||
CUBEMAP_NEGATIVEZ = 0x8000
|
|
||||||
VOLUME = 0x200000
|
|
||||||
|
|
||||||
|
|
||||||
# Pixel Format
|
|
||||||
class DDPF(IntFlag):
|
|
||||||
ALPHAPIXELS = 0x1
|
|
||||||
ALPHA = 0x2
|
|
||||||
FOURCC = 0x4
|
|
||||||
PALETTEINDEXED8 = 0x20
|
|
||||||
RGB = 0x40
|
|
||||||
LUMINANCE = 0x20000
|
|
||||||
|
|
||||||
|
|
||||||
# dxgiformat.h
|
|
||||||
class DXGI_FORMAT(IntEnum):
|
|
||||||
UNKNOWN = 0
|
|
||||||
R32G32B32A32_TYPELESS = 1
|
|
||||||
R32G32B32A32_FLOAT = 2
|
|
||||||
R32G32B32A32_UINT = 3
|
|
||||||
R32G32B32A32_SINT = 4
|
|
||||||
R32G32B32_TYPELESS = 5
|
|
||||||
R32G32B32_FLOAT = 6
|
|
||||||
R32G32B32_UINT = 7
|
|
||||||
R32G32B32_SINT = 8
|
|
||||||
R16G16B16A16_TYPELESS = 9
|
|
||||||
R16G16B16A16_FLOAT = 10
|
|
||||||
R16G16B16A16_UNORM = 11
|
|
||||||
R16G16B16A16_UINT = 12
|
|
||||||
R16G16B16A16_SNORM = 13
|
|
||||||
R16G16B16A16_SINT = 14
|
|
||||||
R32G32_TYPELESS = 15
|
|
||||||
R32G32_FLOAT = 16
|
|
||||||
R32G32_UINT = 17
|
|
||||||
R32G32_SINT = 18
|
|
||||||
R32G8X24_TYPELESS = 19
|
|
||||||
D32_FLOAT_S8X24_UINT = 20
|
|
||||||
R32_FLOAT_X8X24_TYPELESS = 21
|
|
||||||
X32_TYPELESS_G8X24_UINT = 22
|
|
||||||
R10G10B10A2_TYPELESS = 23
|
|
||||||
R10G10B10A2_UNORM = 24
|
|
||||||
R10G10B10A2_UINT = 25
|
|
||||||
R11G11B10_FLOAT = 26
|
|
||||||
R8G8B8A8_TYPELESS = 27
|
|
||||||
R8G8B8A8_UNORM = 28
|
|
||||||
R8G8B8A8_UNORM_SRGB = 29
|
|
||||||
R8G8B8A8_UINT = 30
|
|
||||||
R8G8B8A8_SNORM = 31
|
|
||||||
R8G8B8A8_SINT = 32
|
|
||||||
R16G16_TYPELESS = 33
|
|
||||||
R16G16_FLOAT = 34
|
|
||||||
R16G16_UNORM = 35
|
|
||||||
R16G16_UINT = 36
|
|
||||||
R16G16_SNORM = 37
|
|
||||||
R16G16_SINT = 38
|
|
||||||
R32_TYPELESS = 39
|
|
||||||
D32_FLOAT = 40
|
|
||||||
R32_FLOAT = 41
|
|
||||||
R32_UINT = 42
|
|
||||||
R32_SINT = 43
|
|
||||||
R24G8_TYPELESS = 44
|
|
||||||
D24_UNORM_S8_UINT = 45
|
|
||||||
R24_UNORM_X8_TYPELESS = 46
|
|
||||||
X24_TYPELESS_G8_UINT = 47
|
|
||||||
R8G8_TYPELESS = 48
|
|
||||||
R8G8_UNORM = 49
|
|
||||||
R8G8_UINT = 50
|
|
||||||
R8G8_SNORM = 51
|
|
||||||
R8G8_SINT = 52
|
|
||||||
R16_TYPELESS = 53
|
|
||||||
R16_FLOAT = 54
|
|
||||||
D16_UNORM = 55
|
|
||||||
R16_UNORM = 56
|
|
||||||
R16_UINT = 57
|
|
||||||
R16_SNORM = 58
|
|
||||||
R16_SINT = 59
|
|
||||||
R8_TYPELESS = 60
|
|
||||||
R8_UNORM = 61
|
|
||||||
R8_UINT = 62
|
|
||||||
R8_SNORM = 63
|
|
||||||
R8_SINT = 64
|
|
||||||
A8_UNORM = 65
|
|
||||||
R1_UNORM = 66
|
|
||||||
R9G9B9E5_SHAREDEXP = 67
|
|
||||||
R8G8_B8G8_UNORM = 68
|
|
||||||
G8R8_G8B8_UNORM = 69
|
|
||||||
BC1_TYPELESS = 70
|
|
||||||
BC1_UNORM = 71
|
|
||||||
BC1_UNORM_SRGB = 72
|
|
||||||
BC2_TYPELESS = 73
|
|
||||||
BC2_UNORM = 74
|
|
||||||
BC2_UNORM_SRGB = 75
|
|
||||||
BC3_TYPELESS = 76
|
|
||||||
BC3_UNORM = 77
|
|
||||||
BC3_UNORM_SRGB = 78
|
|
||||||
BC4_TYPELESS = 79
|
|
||||||
BC4_UNORM = 80
|
|
||||||
BC4_SNORM = 81
|
|
||||||
BC5_TYPELESS = 82
|
|
||||||
BC5_UNORM = 83
|
|
||||||
BC5_SNORM = 84
|
|
||||||
B5G6R5_UNORM = 85
|
|
||||||
B5G5R5A1_UNORM = 86
|
|
||||||
B8G8R8A8_UNORM = 87
|
|
||||||
B8G8R8X8_UNORM = 88
|
|
||||||
R10G10B10_XR_BIAS_A2_UNORM = 89
|
|
||||||
B8G8R8A8_TYPELESS = 90
|
|
||||||
B8G8R8A8_UNORM_SRGB = 91
|
|
||||||
B8G8R8X8_TYPELESS = 92
|
|
||||||
B8G8R8X8_UNORM_SRGB = 93
|
|
||||||
BC6H_TYPELESS = 94
|
|
||||||
BC6H_UF16 = 95
|
|
||||||
BC6H_SF16 = 96
|
|
||||||
BC7_TYPELESS = 97
|
|
||||||
BC7_UNORM = 98
|
|
||||||
BC7_UNORM_SRGB = 99
|
|
||||||
AYUV = 100
|
|
||||||
Y410 = 101
|
|
||||||
Y416 = 102
|
|
||||||
NV12 = 103
|
|
||||||
P010 = 104
|
|
||||||
P016 = 105
|
|
||||||
OPAQUE_420 = 106
|
|
||||||
YUY2 = 107
|
|
||||||
Y210 = 108
|
|
||||||
Y216 = 109
|
|
||||||
NV11 = 110
|
|
||||||
AI44 = 111
|
|
||||||
IA44 = 112
|
|
||||||
P8 = 113
|
|
||||||
A8P8 = 114
|
|
||||||
B4G4R4A4_UNORM = 115
|
|
||||||
P208 = 130
|
|
||||||
V208 = 131
|
|
||||||
V408 = 132
|
|
||||||
SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189
|
|
||||||
SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190
|
|
||||||
|
|
||||||
|
|
||||||
class D3DFMT(IntEnum):
|
|
||||||
UNKNOWN = 0
|
|
||||||
R8G8B8 = 20
|
|
||||||
A8R8G8B8 = 21
|
|
||||||
X8R8G8B8 = 22
|
|
||||||
R5G6B5 = 23
|
|
||||||
X1R5G5B5 = 24
|
|
||||||
A1R5G5B5 = 25
|
|
||||||
A4R4G4B4 = 26
|
|
||||||
R3G3B2 = 27
|
|
||||||
A8 = 28
|
|
||||||
A8R3G3B2 = 29
|
|
||||||
X4R4G4B4 = 30
|
|
||||||
A2B10G10R10 = 31
|
|
||||||
A8B8G8R8 = 32
|
|
||||||
X8B8G8R8 = 33
|
|
||||||
G16R16 = 34
|
|
||||||
A2R10G10B10 = 35
|
|
||||||
A16B16G16R16 = 36
|
|
||||||
A8P8 = 40
|
|
||||||
P8 = 41
|
|
||||||
L8 = 50
|
|
||||||
A8L8 = 51
|
|
||||||
A4L4 = 52
|
|
||||||
V8U8 = 60
|
|
||||||
L6V5U5 = 61
|
|
||||||
X8L8V8U8 = 62
|
|
||||||
Q8W8V8U8 = 63
|
|
||||||
V16U16 = 64
|
|
||||||
A2W10V10U10 = 67
|
|
||||||
D16_LOCKABLE = 70
|
|
||||||
D32 = 71
|
|
||||||
D15S1 = 73
|
|
||||||
D24S8 = 75
|
|
||||||
D24X8 = 77
|
|
||||||
D24X4S4 = 79
|
|
||||||
D16 = 80
|
|
||||||
D32F_LOCKABLE = 82
|
|
||||||
D24FS8 = 83
|
|
||||||
D32_LOCKABLE = 84
|
|
||||||
S8_LOCKABLE = 85
|
|
||||||
L16 = 81
|
|
||||||
VERTEXDATA = 100
|
|
||||||
INDEX16 = 101
|
|
||||||
INDEX32 = 102
|
|
||||||
Q16W16V16U16 = 110
|
|
||||||
R16F = 111
|
|
||||||
G16R16F = 112
|
|
||||||
A16B16G16R16F = 113
|
|
||||||
R32F = 114
|
|
||||||
G32R32F = 115
|
|
||||||
A32B32G32R32F = 116
|
|
||||||
CxV8U8 = 117
|
|
||||||
A1 = 118
|
|
||||||
A2B10G10R10_XR_BIAS = 119
|
|
||||||
BINARYBUFFER = 199
|
|
||||||
|
|
||||||
UYVY = i32(b"UYVY")
|
|
||||||
R8G8_B8G8 = i32(b"RGBG")
|
|
||||||
YUY2 = i32(b"YUY2")
|
|
||||||
G8R8_G8B8 = i32(b"GRGB")
|
|
||||||
DXT1 = i32(b"DXT1")
|
|
||||||
DXT2 = i32(b"DXT2")
|
|
||||||
DXT3 = i32(b"DXT3")
|
|
||||||
DXT4 = i32(b"DXT4")
|
|
||||||
DXT5 = i32(b"DXT5")
|
|
||||||
DX10 = i32(b"DX10")
|
|
||||||
BC4S = i32(b"BC4S")
|
|
||||||
BC4U = i32(b"BC4U")
|
|
||||||
BC5S = i32(b"BC5S")
|
|
||||||
BC5U = i32(b"BC5U")
|
|
||||||
ATI1 = i32(b"ATI1")
|
|
||||||
ATI2 = i32(b"ATI2")
|
|
||||||
MULTI2_ARGB8 = i32(b"MET1")
|
|
||||||
|
|
||||||
|
|
||||||
# Backward compatibility layer
|
|
||||||
module = sys.modules[__name__]
|
|
||||||
for item in DDSD:
|
|
||||||
assert item.name is not None
|
|
||||||
setattr(module, f"DDSD_{item.name}", item.value)
|
|
||||||
for item1 in DDSCAPS:
|
|
||||||
assert item1.name is not None
|
|
||||||
setattr(module, f"DDSCAPS_{item1.name}", item1.value)
|
|
||||||
for item2 in DDSCAPS2:
|
|
||||||
assert item2.name is not None
|
|
||||||
setattr(module, f"DDSCAPS2_{item2.name}", item2.value)
|
|
||||||
for item3 in DDPF:
|
|
||||||
assert item3.name is not None
|
|
||||||
setattr(module, f"DDPF_{item3.name}", item3.value)
|
|
||||||
|
|
||||||
DDS_FOURCC = DDPF.FOURCC
|
|
||||||
DDS_RGB = DDPF.RGB
|
|
||||||
DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS
|
|
||||||
DDS_LUMINANCE = DDPF.LUMINANCE
|
|
||||||
DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS
|
|
||||||
DDS_ALPHA = DDPF.ALPHA
|
|
||||||
DDS_PAL8 = DDPF.PALETTEINDEXED8
|
|
||||||
|
|
||||||
DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
|
|
||||||
DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT
|
|
||||||
DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH
|
|
||||||
DDS_HEADER_FLAGS_PITCH = DDSD.PITCH
|
|
||||||
DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE
|
|
||||||
|
|
||||||
DDS_HEIGHT = DDSD.HEIGHT
|
|
||||||
DDS_WIDTH = DDSD.WIDTH
|
|
||||||
|
|
||||||
DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE
|
|
||||||
DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP
|
|
||||||
DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX
|
|
||||||
|
|
||||||
DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX
|
|
||||||
DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX
|
|
||||||
DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY
|
|
||||||
DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY
|
|
||||||
DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ
|
|
||||||
DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ
|
|
||||||
|
|
||||||
DXT1_FOURCC = D3DFMT.DXT1
|
|
||||||
DXT3_FOURCC = D3DFMT.DXT3
|
|
||||||
DXT5_FOURCC = D3DFMT.DXT5
|
|
||||||
|
|
||||||
DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS
|
|
||||||
DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM
|
|
||||||
DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB
|
|
||||||
DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS
|
|
||||||
DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM
|
|
||||||
DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM
|
|
||||||
DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16
|
|
||||||
DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16
|
|
||||||
DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS
|
|
||||||
DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM
|
|
||||||
DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB
|
|
||||||
|
|
||||||
|
|
||||||
class DdsImageFile(ImageFile.ImageFile):
|
|
||||||
format = "DDS"
|
|
||||||
format_description = "DirectDraw Surface"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
if not _accept(self.fp.read(4)):
|
|
||||||
msg = "not a DDS file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
(header_size,) = struct.unpack("<I", self.fp.read(4))
|
|
||||||
if header_size != 124:
|
|
||||||
msg = f"Unsupported header size {repr(header_size)}"
|
|
||||||
raise OSError(msg)
|
|
||||||
header = self.fp.read(header_size - 4)
|
|
||||||
if len(header) != 120:
|
|
||||||
msg = f"Incomplete header: {len(header)} bytes"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
flags, height, width = struct.unpack("<3I", header[:12])
|
|
||||||
self._size = (width, height)
|
|
||||||
extents = (0, 0) + self.size
|
|
||||||
|
|
||||||
pitch, depth, mipmaps = struct.unpack("<3I", header[12:24])
|
|
||||||
struct.unpack("<11I", header[24:68]) # reserved
|
|
||||||
|
|
||||||
# pixel format
|
|
||||||
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header[68:84])
|
|
||||||
n = 0
|
|
||||||
rawmode = None
|
|
||||||
if pfflags & DDPF.RGB:
|
|
||||||
# Texture contains uncompressed RGB data
|
|
||||||
if pfflags & DDPF.ALPHAPIXELS:
|
|
||||||
self._mode = "RGBA"
|
|
||||||
mask_count = 4
|
|
||||||
else:
|
|
||||||
self._mode = "RGB"
|
|
||||||
mask_count = 3
|
|
||||||
|
|
||||||
masks = struct.unpack(f"<{mask_count}I", header[84 : 84 + mask_count * 4])
|
|
||||||
self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))]
|
|
||||||
return
|
|
||||||
elif pfflags & DDPF.LUMINANCE:
|
|
||||||
if bitcount == 8:
|
|
||||||
self._mode = "L"
|
|
||||||
elif bitcount == 16 and pfflags & DDPF.ALPHAPIXELS:
|
|
||||||
self._mode = "LA"
|
|
||||||
else:
|
|
||||||
msg = f"Unsupported bitcount {bitcount} for {pfflags}"
|
|
||||||
raise OSError(msg)
|
|
||||||
elif pfflags & DDPF.PALETTEINDEXED8:
|
|
||||||
self._mode = "P"
|
|
||||||
self.palette = ImagePalette.raw("RGBA", self.fp.read(1024))
|
|
||||||
self.palette.mode = "RGBA"
|
|
||||||
elif pfflags & DDPF.FOURCC:
|
|
||||||
offset = header_size + 4
|
|
||||||
if fourcc == D3DFMT.DXT1:
|
|
||||||
self._mode = "RGBA"
|
|
||||||
self.pixel_format = "DXT1"
|
|
||||||
n = 1
|
|
||||||
elif fourcc == D3DFMT.DXT3:
|
|
||||||
self._mode = "RGBA"
|
|
||||||
self.pixel_format = "DXT3"
|
|
||||||
n = 2
|
|
||||||
elif fourcc == D3DFMT.DXT5:
|
|
||||||
self._mode = "RGBA"
|
|
||||||
self.pixel_format = "DXT5"
|
|
||||||
n = 3
|
|
||||||
elif fourcc in (D3DFMT.BC4U, D3DFMT.ATI1):
|
|
||||||
self._mode = "L"
|
|
||||||
self.pixel_format = "BC4"
|
|
||||||
n = 4
|
|
||||||
elif fourcc == D3DFMT.BC5S:
|
|
||||||
self._mode = "RGB"
|
|
||||||
self.pixel_format = "BC5S"
|
|
||||||
n = 5
|
|
||||||
elif fourcc in (D3DFMT.BC5U, D3DFMT.ATI2):
|
|
||||||
self._mode = "RGB"
|
|
||||||
self.pixel_format = "BC5"
|
|
||||||
n = 5
|
|
||||||
elif fourcc == D3DFMT.DX10:
|
|
||||||
offset += 20
|
|
||||||
# ignoring flags which pertain to volume textures and cubemaps
|
|
||||||
(dxgi_format,) = struct.unpack("<I", self.fp.read(4))
|
|
||||||
self.fp.read(16)
|
|
||||||
if dxgi_format in (
|
|
||||||
DXGI_FORMAT.BC1_UNORM,
|
|
||||||
DXGI_FORMAT.BC1_TYPELESS,
|
|
||||||
):
|
|
||||||
self._mode = "RGBA"
|
|
||||||
self.pixel_format = "BC1"
|
|
||||||
n = 1
|
|
||||||
elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM):
|
|
||||||
self._mode = "RGBA"
|
|
||||||
self.pixel_format = "BC2"
|
|
||||||
n = 2
|
|
||||||
elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM):
|
|
||||||
self._mode = "RGBA"
|
|
||||||
self.pixel_format = "BC3"
|
|
||||||
n = 3
|
|
||||||
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
|
|
||||||
self._mode = "L"
|
|
||||||
self.pixel_format = "BC4"
|
|
||||||
n = 4
|
|
||||||
elif dxgi_format in (DXGI_FORMAT.BC5_TYPELESS, DXGI_FORMAT.BC5_UNORM):
|
|
||||||
self._mode = "RGB"
|
|
||||||
self.pixel_format = "BC5"
|
|
||||||
n = 5
|
|
||||||
elif dxgi_format == DXGI_FORMAT.BC5_SNORM:
|
|
||||||
self._mode = "RGB"
|
|
||||||
self.pixel_format = "BC5S"
|
|
||||||
n = 5
|
|
||||||
elif dxgi_format == DXGI_FORMAT.BC6H_UF16:
|
|
||||||
self._mode = "RGB"
|
|
||||||
self.pixel_format = "BC6H"
|
|
||||||
n = 6
|
|
||||||
elif dxgi_format == DXGI_FORMAT.BC6H_SF16:
|
|
||||||
self._mode = "RGB"
|
|
||||||
self.pixel_format = "BC6HS"
|
|
||||||
n = 6
|
|
||||||
elif dxgi_format in (
|
|
||||||
DXGI_FORMAT.BC7_TYPELESS,
|
|
||||||
DXGI_FORMAT.BC7_UNORM,
|
|
||||||
DXGI_FORMAT.BC7_UNORM_SRGB,
|
|
||||||
):
|
|
||||||
self._mode = "RGBA"
|
|
||||||
self.pixel_format = "BC7"
|
|
||||||
n = 7
|
|
||||||
if dxgi_format == DXGI_FORMAT.BC7_UNORM_SRGB:
|
|
||||||
self.info["gamma"] = 1 / 2.2
|
|
||||||
elif dxgi_format in (
|
|
||||||
DXGI_FORMAT.R8G8B8A8_TYPELESS,
|
|
||||||
DXGI_FORMAT.R8G8B8A8_UNORM,
|
|
||||||
DXGI_FORMAT.R8G8B8A8_UNORM_SRGB,
|
|
||||||
):
|
|
||||||
self._mode = "RGBA"
|
|
||||||
if dxgi_format == DXGI_FORMAT.R8G8B8A8_UNORM_SRGB:
|
|
||||||
self.info["gamma"] = 1 / 2.2
|
|
||||||
else:
|
|
||||||
msg = f"Unimplemented DXGI format {dxgi_format}"
|
|
||||||
raise NotImplementedError(msg)
|
|
||||||
else:
|
|
||||||
msg = f"Unimplemented pixel format {repr(fourcc)}"
|
|
||||||
raise NotImplementedError(msg)
|
|
||||||
else:
|
|
||||||
msg = f"Unknown pixel format flags {pfflags}"
|
|
||||||
raise NotImplementedError(msg)
|
|
||||||
|
|
||||||
if n:
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format))
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)]
|
|
||||||
|
|
||||||
def load_seek(self, pos: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DdsRgbDecoder(ImageFile.PyDecoder):
|
|
||||||
_pulls_fd = True
|
|
||||||
|
|
||||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
|
||||||
assert self.fd is not None
|
|
||||||
bitcount, masks = self.args
|
|
||||||
|
|
||||||
# Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
|
|
||||||
# Calculate how many zeros each mask is padded with
|
|
||||||
mask_offsets = []
|
|
||||||
# And the maximum value of each channel without the padding
|
|
||||||
mask_totals = []
|
|
||||||
for mask in masks:
|
|
||||||
offset = 0
|
|
||||||
if mask != 0:
|
|
||||||
while mask >> (offset + 1) << (offset + 1) == mask:
|
|
||||||
offset += 1
|
|
||||||
mask_offsets.append(offset)
|
|
||||||
mask_totals.append(mask >> offset)
|
|
||||||
|
|
||||||
data = bytearray()
|
|
||||||
bytecount = bitcount // 8
|
|
||||||
dest_length = self.state.xsize * self.state.ysize * len(masks)
|
|
||||||
while len(data) < dest_length:
|
|
||||||
value = int.from_bytes(self.fd.read(bytecount), "little")
|
|
||||||
for i, mask in enumerate(masks):
|
|
||||||
masked_value = value & mask
|
|
||||||
# Remove the zero padding, and scale it to 8 bits
|
|
||||||
data += o8(
|
|
||||||
int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
|
|
||||||
if mask_totals[i]
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
self.set_as_raw(data)
|
|
||||||
return -1, 0
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
if im.mode not in ("RGB", "RGBA", "L", "LA"):
|
|
||||||
msg = f"cannot write mode {im.mode} as DDS"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
|
|
||||||
bitcount = len(im.getbands()) * 8
|
|
||||||
pixel_format = im.encoderinfo.get("pixel_format")
|
|
||||||
args: tuple[int] | str
|
|
||||||
if pixel_format:
|
|
||||||
codec_name = "bcn"
|
|
||||||
flags |= DDSD.LINEARSIZE
|
|
||||||
pitch = (im.width + 3) * 4
|
|
||||||
rgba_mask = [0, 0, 0, 0]
|
|
||||||
pixel_flags = DDPF.FOURCC
|
|
||||||
if pixel_format == "DXT1":
|
|
||||||
fourcc = D3DFMT.DXT1
|
|
||||||
args = (1,)
|
|
||||||
elif pixel_format == "DXT3":
|
|
||||||
fourcc = D3DFMT.DXT3
|
|
||||||
args = (2,)
|
|
||||||
elif pixel_format == "DXT5":
|
|
||||||
fourcc = D3DFMT.DXT5
|
|
||||||
args = (3,)
|
|
||||||
else:
|
|
||||||
fourcc = D3DFMT.DX10
|
|
||||||
if pixel_format == "BC2":
|
|
||||||
args = (2,)
|
|
||||||
dxgi_format = DXGI_FORMAT.BC2_TYPELESS
|
|
||||||
elif pixel_format == "BC3":
|
|
||||||
args = (3,)
|
|
||||||
dxgi_format = DXGI_FORMAT.BC3_TYPELESS
|
|
||||||
elif pixel_format == "BC5":
|
|
||||||
args = (5,)
|
|
||||||
dxgi_format = DXGI_FORMAT.BC5_TYPELESS
|
|
||||||
if im.mode != "RGB":
|
|
||||||
msg = "only RGB mode can be written as BC5"
|
|
||||||
raise OSError(msg)
|
|
||||||
else:
|
|
||||||
msg = f"cannot write pixel format {pixel_format}"
|
|
||||||
raise OSError(msg)
|
|
||||||
else:
|
|
||||||
codec_name = "raw"
|
|
||||||
flags |= DDSD.PITCH
|
|
||||||
pitch = (im.width * bitcount + 7) // 8
|
|
||||||
|
|
||||||
alpha = im.mode[-1] == "A"
|
|
||||||
if im.mode[0] == "L":
|
|
||||||
pixel_flags = DDPF.LUMINANCE
|
|
||||||
args = im.mode
|
|
||||||
if alpha:
|
|
||||||
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
|
|
||||||
else:
|
|
||||||
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
|
|
||||||
else:
|
|
||||||
pixel_flags = DDPF.RGB
|
|
||||||
args = im.mode[::-1]
|
|
||||||
rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
|
|
||||||
|
|
||||||
if alpha:
|
|
||||||
r, g, b, a = im.split()
|
|
||||||
im = Image.merge("RGBA", (a, r, g, b))
|
|
||||||
if alpha:
|
|
||||||
pixel_flags |= DDPF.ALPHAPIXELS
|
|
||||||
rgba_mask.append(0xFF000000 if alpha else 0)
|
|
||||||
|
|
||||||
fourcc = D3DFMT.UNKNOWN
|
|
||||||
fp.write(
|
|
||||||
o32(DDS_MAGIC)
|
|
||||||
+ struct.pack(
|
|
||||||
"<7I",
|
|
||||||
124, # header size
|
|
||||||
flags, # flags
|
|
||||||
im.height,
|
|
||||||
im.width,
|
|
||||||
pitch,
|
|
||||||
0, # depth
|
|
||||||
0, # mipmaps
|
|
||||||
)
|
|
||||||
+ struct.pack("11I", *((0,) * 11)) # reserved
|
|
||||||
# pfsize, pfflags, fourcc, bitcount
|
|
||||||
+ struct.pack("<4I", 32, pixel_flags, fourcc, bitcount)
|
|
||||||
+ struct.pack("<4I", *rgba_mask) # dwRGBABitMask
|
|
||||||
+ struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0)
|
|
||||||
)
|
|
||||||
if fourcc == D3DFMT.DX10:
|
|
||||||
fp.write(
|
|
||||||
# dxgi_format, 2D resource, misc, array size, straight alpha
|
|
||||||
struct.pack("<5I", dxgi_format, 3, 0, 0, 1)
|
|
||||||
)
|
|
||||||
ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)])
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(b"DDS ")
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(DdsImageFile.format, DdsImageFile, _accept)
|
|
||||||
Image.register_decoder("dds_rgb", DdsRgbDecoder)
|
|
||||||
Image.register_save(DdsImageFile.format, _save)
|
|
||||||
Image.register_extension(DdsImageFile.format, ".dds")
|
|
||||||
@@ -1,481 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# EPS file handling
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1995-09-01 fl Created (0.1)
|
|
||||||
# 1996-05-18 fl Don't choke on "atend" fields, Ghostscript interface (0.2)
|
|
||||||
# 1996-08-22 fl Don't choke on floating point BoundingBox values
|
|
||||||
# 1996-08-23 fl Handle files from Macintosh (0.3)
|
|
||||||
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4)
|
|
||||||
# 2003-09-07 fl Check gs.close status (from Federico Di Gregorio) (0.5)
|
|
||||||
# 2014-05-07 e Handling of EPS with binary preview and fixed resolution
|
|
||||||
# resizing
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
|
||||||
# Copyright (c) 1995-2003 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
from ._binary import i32le as i32
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
|
|
||||||
field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")
|
|
||||||
|
|
||||||
gs_binary: str | bool | None = None
|
|
||||||
gs_windows_binary = None
|
|
||||||
|
|
||||||
|
|
||||||
def has_ghostscript() -> bool:
|
|
||||||
global gs_binary, gs_windows_binary
|
|
||||||
if gs_binary is None:
|
|
||||||
if sys.platform.startswith("win"):
|
|
||||||
if gs_windows_binary is None:
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
for binary in ("gswin32c", "gswin64c", "gs"):
|
|
||||||
if shutil.which(binary) is not None:
|
|
||||||
gs_windows_binary = binary
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
gs_windows_binary = False
|
|
||||||
gs_binary = gs_windows_binary
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL)
|
|
||||||
gs_binary = "gs"
|
|
||||||
except OSError:
|
|
||||||
gs_binary = False
|
|
||||||
return gs_binary is not False
|
|
||||||
|
|
||||||
|
|
||||||
def Ghostscript(
|
|
||||||
tile: list[ImageFile._Tile],
|
|
||||||
size: tuple[int, int],
|
|
||||||
fp: IO[bytes],
|
|
||||||
scale: int = 1,
|
|
||||||
transparency: bool = False,
|
|
||||||
) -> Image.core.ImagingCore:
|
|
||||||
"""Render an image using Ghostscript"""
|
|
||||||
global gs_binary
|
|
||||||
if not has_ghostscript():
|
|
||||||
msg = "Unable to locate Ghostscript on paths"
|
|
||||||
raise OSError(msg)
|
|
||||||
assert isinstance(gs_binary, str)
|
|
||||||
|
|
||||||
# Unpack decoder tile
|
|
||||||
args = tile[0].args
|
|
||||||
assert isinstance(args, tuple)
|
|
||||||
length, bbox = args
|
|
||||||
|
|
||||||
# Hack to support hi-res rendering
|
|
||||||
scale = int(scale) or 1
|
|
||||||
width = size[0] * scale
|
|
||||||
height = size[1] * scale
|
|
||||||
# resolution is dependent on bbox and size
|
|
||||||
res_x = 72.0 * width / (bbox[2] - bbox[0])
|
|
||||||
res_y = 72.0 * height / (bbox[3] - bbox[1])
|
|
||||||
|
|
||||||
out_fd, outfile = tempfile.mkstemp()
|
|
||||||
os.close(out_fd)
|
|
||||||
|
|
||||||
infile_temp = None
|
|
||||||
if hasattr(fp, "name") and os.path.exists(fp.name):
|
|
||||||
infile = fp.name
|
|
||||||
else:
|
|
||||||
in_fd, infile_temp = tempfile.mkstemp()
|
|
||||||
os.close(in_fd)
|
|
||||||
infile = infile_temp
|
|
||||||
|
|
||||||
# Ignore length and offset!
|
|
||||||
# Ghostscript can read it
|
|
||||||
# Copy whole file to read in Ghostscript
|
|
||||||
with open(infile_temp, "wb") as f:
|
|
||||||
# fetch length of fp
|
|
||||||
fp.seek(0, io.SEEK_END)
|
|
||||||
fsize = fp.tell()
|
|
||||||
# ensure start position
|
|
||||||
# go back
|
|
||||||
fp.seek(0)
|
|
||||||
lengthfile = fsize
|
|
||||||
while lengthfile > 0:
|
|
||||||
s = fp.read(min(lengthfile, 100 * 1024))
|
|
||||||
if not s:
|
|
||||||
break
|
|
||||||
lengthfile -= len(s)
|
|
||||||
f.write(s)
|
|
||||||
|
|
||||||
if transparency:
|
|
||||||
# "RGBA"
|
|
||||||
device = "pngalpha"
|
|
||||||
else:
|
|
||||||
# "pnmraw" automatically chooses between
|
|
||||||
# PBM ("1"), PGM ("L"), and PPM ("RGB").
|
|
||||||
device = "pnmraw"
|
|
||||||
|
|
||||||
# Build Ghostscript command
|
|
||||||
command = [
|
|
||||||
gs_binary,
|
|
||||||
"-q", # quiet mode
|
|
||||||
f"-g{width:d}x{height:d}", # set output geometry (pixels)
|
|
||||||
f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch)
|
|
||||||
"-dBATCH", # exit after processing
|
|
||||||
"-dNOPAUSE", # don't pause between pages
|
|
||||||
"-dSAFER", # safe mode
|
|
||||||
f"-sDEVICE={device}",
|
|
||||||
f"-sOutputFile={outfile}", # output file
|
|
||||||
# adjust for image origin
|
|
||||||
"-c",
|
|
||||||
f"{-bbox[0]} {-bbox[1]} translate",
|
|
||||||
"-f",
|
|
||||||
infile, # input file
|
|
||||||
# showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272)
|
|
||||||
"-c",
|
|
||||||
"showpage",
|
|
||||||
]
|
|
||||||
|
|
||||||
# push data through Ghostscript
|
|
||||||
try:
|
|
||||||
startupinfo = None
|
|
||||||
if sys.platform.startswith("win"):
|
|
||||||
startupinfo = subprocess.STARTUPINFO()
|
|
||||||
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
||||||
subprocess.check_call(command, startupinfo=startupinfo)
|
|
||||||
with Image.open(outfile) as out_im:
|
|
||||||
out_im.load()
|
|
||||||
return out_im.im.copy()
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(outfile)
|
|
||||||
if infile_temp:
|
|
||||||
os.unlink(infile_temp)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(b"%!PS") or (
|
|
||||||
len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for Encapsulated PostScript. This plugin supports only
|
|
||||||
# a few variants of this format.
|
|
||||||
|
|
||||||
|
|
||||||
class EpsImageFile(ImageFile.ImageFile):
|
|
||||||
"""EPS File Parser for the Python Imaging Library"""
|
|
||||||
|
|
||||||
format = "EPS"
|
|
||||||
format_description = "Encapsulated Postscript"
|
|
||||||
|
|
||||||
mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
length, offset = self._find_offset(self.fp)
|
|
||||||
|
|
||||||
# go to offset - start of "%!PS"
|
|
||||||
self.fp.seek(offset)
|
|
||||||
|
|
||||||
self._mode = "RGB"
|
|
||||||
|
|
||||||
# When reading header comments, the first comment is used.
|
|
||||||
# When reading trailer comments, the last comment is used.
|
|
||||||
bounding_box: list[int] | None = None
|
|
||||||
imagedata_size: tuple[int, int] | None = None
|
|
||||||
|
|
||||||
byte_arr = bytearray(255)
|
|
||||||
bytes_mv = memoryview(byte_arr)
|
|
||||||
bytes_read = 0
|
|
||||||
reading_header_comments = True
|
|
||||||
reading_trailer_comments = False
|
|
||||||
trailer_reached = False
|
|
||||||
|
|
||||||
def check_required_header_comments() -> None:
|
|
||||||
"""
|
|
||||||
The EPS specification requires that some headers exist.
|
|
||||||
This should be checked when the header comments formally end,
|
|
||||||
when image data starts, or when the file ends, whichever comes first.
|
|
||||||
"""
|
|
||||||
if "PS-Adobe" not in self.info:
|
|
||||||
msg = 'EPS header missing "%!PS-Adobe" comment'
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
if "BoundingBox" not in self.info:
|
|
||||||
msg = 'EPS header missing "%%BoundingBox" comment'
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
def read_comment(s: str) -> bool:
|
|
||||||
nonlocal bounding_box, reading_trailer_comments
|
|
||||||
try:
|
|
||||||
m = split.match(s)
|
|
||||||
except re.error as e:
|
|
||||||
msg = "not an EPS file"
|
|
||||||
raise SyntaxError(msg) from e
|
|
||||||
|
|
||||||
if not m:
|
|
||||||
return False
|
|
||||||
|
|
||||||
k, v = m.group(1, 2)
|
|
||||||
self.info[k] = v
|
|
||||||
if k == "BoundingBox":
|
|
||||||
if v == "(atend)":
|
|
||||||
reading_trailer_comments = True
|
|
||||||
elif not bounding_box or (trailer_reached and reading_trailer_comments):
|
|
||||||
try:
|
|
||||||
# Note: The DSC spec says that BoundingBox
|
|
||||||
# fields should be integers, but some drivers
|
|
||||||
# put floating point values there anyway.
|
|
||||||
bounding_box = [int(float(i)) for i in v.split()]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return True
|
|
||||||
|
|
||||||
while True:
|
|
||||||
byte = self.fp.read(1)
|
|
||||||
if byte == b"":
|
|
||||||
# if we didn't read a byte we must be at the end of the file
|
|
||||||
if bytes_read == 0:
|
|
||||||
if reading_header_comments:
|
|
||||||
check_required_header_comments()
|
|
||||||
break
|
|
||||||
elif byte in b"\r\n":
|
|
||||||
# if we read a line ending character, ignore it and parse what
|
|
||||||
# we have already read. if we haven't read any other characters,
|
|
||||||
# continue reading
|
|
||||||
if bytes_read == 0:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# ASCII/hexadecimal lines in an EPS file must not exceed
|
|
||||||
# 255 characters, not including line ending characters
|
|
||||||
if bytes_read >= 255:
|
|
||||||
# only enforce this for lines starting with a "%",
|
|
||||||
# otherwise assume it's binary data
|
|
||||||
if byte_arr[0] == ord("%"):
|
|
||||||
msg = "not an EPS file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
else:
|
|
||||||
if reading_header_comments:
|
|
||||||
check_required_header_comments()
|
|
||||||
reading_header_comments = False
|
|
||||||
# reset bytes_read so we can keep reading
|
|
||||||
# data until the end of the line
|
|
||||||
bytes_read = 0
|
|
||||||
byte_arr[bytes_read] = byte[0]
|
|
||||||
bytes_read += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if reading_header_comments:
|
|
||||||
# Load EPS header
|
|
||||||
|
|
||||||
# if this line doesn't start with a "%",
|
|
||||||
# or does start with "%%EndComments",
|
|
||||||
# then we've reached the end of the header/comments
|
|
||||||
if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments":
|
|
||||||
check_required_header_comments()
|
|
||||||
reading_header_comments = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
s = str(bytes_mv[:bytes_read], "latin-1")
|
|
||||||
if not read_comment(s):
|
|
||||||
m = field.match(s)
|
|
||||||
if m:
|
|
||||||
k = m.group(1)
|
|
||||||
if k.startswith("PS-Adobe"):
|
|
||||||
self.info["PS-Adobe"] = k[9:]
|
|
||||||
else:
|
|
||||||
self.info[k] = ""
|
|
||||||
elif s[0] == "%":
|
|
||||||
# handle non-DSC PostScript comments that some
|
|
||||||
# tools mistakenly put in the Comments section
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
msg = "bad EPS header"
|
|
||||||
raise OSError(msg)
|
|
||||||
elif bytes_mv[:11] == b"%ImageData:":
|
|
||||||
# Check for an "ImageData" descriptor
|
|
||||||
# https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096
|
|
||||||
|
|
||||||
# If we've already read an "ImageData" descriptor,
|
|
||||||
# don't read another one.
|
|
||||||
if imagedata_size:
|
|
||||||
bytes_read = 0
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Values:
|
|
||||||
# columns
|
|
||||||
# rows
|
|
||||||
# bit depth (1 or 8)
|
|
||||||
# mode (1: L, 2: LAB, 3: RGB, 4: CMYK)
|
|
||||||
# number of padding channels
|
|
||||||
# block size (number of bytes per row per channel)
|
|
||||||
# binary/ascii (1: binary, 2: ascii)
|
|
||||||
# data start identifier (the image data follows after a single line
|
|
||||||
# consisting only of this quoted value)
|
|
||||||
image_data_values = byte_arr[11:bytes_read].split(None, 7)
|
|
||||||
columns, rows, bit_depth, mode_id = (
|
|
||||||
int(value) for value in image_data_values[:4]
|
|
||||||
)
|
|
||||||
|
|
||||||
if bit_depth == 1:
|
|
||||||
self._mode = "1"
|
|
||||||
elif bit_depth == 8:
|
|
||||||
try:
|
|
||||||
self._mode = self.mode_map[mode_id]
|
|
||||||
except ValueError:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
# Parse the columns and rows after checking the bit depth and mode
|
|
||||||
# in case the bit depth and/or mode are invalid.
|
|
||||||
imagedata_size = columns, rows
|
|
||||||
elif bytes_mv[:5] == b"%%EOF":
|
|
||||||
break
|
|
||||||
elif trailer_reached and reading_trailer_comments:
|
|
||||||
# Load EPS trailer
|
|
||||||
s = str(bytes_mv[:bytes_read], "latin-1")
|
|
||||||
read_comment(s)
|
|
||||||
elif bytes_mv[:9] == b"%%Trailer":
|
|
||||||
trailer_reached = True
|
|
||||||
elif bytes_mv[:14] == b"%%BeginBinary:":
|
|
||||||
bytecount = int(byte_arr[14:bytes_read])
|
|
||||||
self.fp.seek(bytecount, os.SEEK_CUR)
|
|
||||||
bytes_read = 0
|
|
||||||
|
|
||||||
# A "BoundingBox" is always required,
|
|
||||||
# even if an "ImageData" descriptor size exists.
|
|
||||||
if not bounding_box:
|
|
||||||
msg = "cannot determine EPS bounding box"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
# An "ImageData" size takes precedence over the "BoundingBox".
|
|
||||||
self._size = imagedata_size or (
|
|
||||||
bounding_box[2] - bounding_box[0],
|
|
||||||
bounding_box[3] - bounding_box[1],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box))
|
|
||||||
]
|
|
||||||
|
|
||||||
def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
|
|
||||||
s = fp.read(4)
|
|
||||||
|
|
||||||
if s == b"%!PS":
|
|
||||||
# for HEAD without binary preview
|
|
||||||
fp.seek(0, io.SEEK_END)
|
|
||||||
length = fp.tell()
|
|
||||||
offset = 0
|
|
||||||
elif i32(s) == 0xC6D3D0C5:
|
|
||||||
# FIX for: Some EPS file not handled correctly / issue #302
|
|
||||||
# EPS can contain binary data
|
|
||||||
# or start directly with latin coding
|
|
||||||
# more info see:
|
|
||||||
# https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf
|
|
||||||
s = fp.read(8)
|
|
||||||
offset = i32(s)
|
|
||||||
length = i32(s, 4)
|
|
||||||
else:
|
|
||||||
msg = "not an EPS file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
return length, offset
|
|
||||||
|
|
||||||
def load(
|
|
||||||
self, scale: int = 1, transparency: bool = False
|
|
||||||
) -> Image.core.PixelAccess | None:
|
|
||||||
# Load EPS via Ghostscript
|
|
||||||
if self.tile:
|
|
||||||
assert self.fp is not None
|
|
||||||
self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
|
|
||||||
self._mode = self.im.mode
|
|
||||||
self._size = self.im.size
|
|
||||||
self.tile = []
|
|
||||||
return Image.Image.load(self)
|
|
||||||
|
|
||||||
def load_seek(self, pos: int) -> None:
|
|
||||||
# we can't incrementally load, so force ImageFile.parser to
|
|
||||||
# use our custom load method by defining this method.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
|
|
||||||
"""EPS Writer for the Python Imaging Library."""
|
|
||||||
|
|
||||||
# make sure image data is available
|
|
||||||
im.load()
|
|
||||||
|
|
||||||
# determine PostScript image mode
|
|
||||||
if im.mode == "L":
|
|
||||||
operator = (8, 1, b"image")
|
|
||||||
elif im.mode == "RGB":
|
|
||||||
operator = (8, 3, b"false 3 colorimage")
|
|
||||||
elif im.mode == "CMYK":
|
|
||||||
operator = (8, 4, b"false 4 colorimage")
|
|
||||||
else:
|
|
||||||
msg = "image mode is not supported"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
if eps:
|
|
||||||
# write EPS header
|
|
||||||
fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
|
|
||||||
fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
|
|
||||||
# fp.write("%%CreationDate: %s"...)
|
|
||||||
fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
|
|
||||||
fp.write(b"%%Pages: 1\n")
|
|
||||||
fp.write(b"%%EndComments\n")
|
|
||||||
fp.write(b"%%Page: 1 1\n")
|
|
||||||
fp.write(b"%%ImageData: %d %d " % im.size)
|
|
||||||
fp.write(b'%d %d 0 1 1 "%s"\n' % operator)
|
|
||||||
|
|
||||||
# image header
|
|
||||||
fp.write(b"gsave\n")
|
|
||||||
fp.write(b"10 dict begin\n")
|
|
||||||
fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
|
|
||||||
fp.write(b"%d %d scale\n" % im.size)
|
|
||||||
fp.write(b"%d %d 8\n" % im.size) # <= bits
|
|
||||||
fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
|
|
||||||
fp.write(b"{ currentfile buf readhexstring pop } bind\n")
|
|
||||||
fp.write(operator[2] + b"\n")
|
|
||||||
if hasattr(fp, "flush"):
|
|
||||||
fp.flush()
|
|
||||||
|
|
||||||
ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)])
|
|
||||||
|
|
||||||
fp.write(b"\n%%%%EndBinary\n")
|
|
||||||
fp.write(b"grestore end\n")
|
|
||||||
if hasattr(fp, "flush"):
|
|
||||||
fp.flush()
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(EpsImageFile.format, EpsImageFile, _accept)
|
|
||||||
|
|
||||||
Image.register_save(EpsImageFile.format, _save)
|
|
||||||
|
|
||||||
Image.register_extensions(EpsImageFile.format, [".ps", ".eps"])
|
|
||||||
|
|
||||||
Image.register_mime(EpsImageFile.format, "application/postscript")
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# EXIF tags
|
|
||||||
#
|
|
||||||
# Copyright (c) 2003 by Secret Labs AB
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
|
|
||||||
"""
|
|
||||||
This module provides constants and clear-text names for various
|
|
||||||
well-known EXIF tags.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import IntEnum
|
|
||||||
|
|
||||||
|
|
||||||
class Base(IntEnum):
|
|
||||||
# possibly incomplete
|
|
||||||
InteropIndex = 0x0001
|
|
||||||
ProcessingSoftware = 0x000B
|
|
||||||
NewSubfileType = 0x00FE
|
|
||||||
SubfileType = 0x00FF
|
|
||||||
ImageWidth = 0x0100
|
|
||||||
ImageLength = 0x0101
|
|
||||||
BitsPerSample = 0x0102
|
|
||||||
Compression = 0x0103
|
|
||||||
PhotometricInterpretation = 0x0106
|
|
||||||
Thresholding = 0x0107
|
|
||||||
CellWidth = 0x0108
|
|
||||||
CellLength = 0x0109
|
|
||||||
FillOrder = 0x010A
|
|
||||||
DocumentName = 0x010D
|
|
||||||
ImageDescription = 0x010E
|
|
||||||
Make = 0x010F
|
|
||||||
Model = 0x0110
|
|
||||||
StripOffsets = 0x0111
|
|
||||||
Orientation = 0x0112
|
|
||||||
SamplesPerPixel = 0x0115
|
|
||||||
RowsPerStrip = 0x0116
|
|
||||||
StripByteCounts = 0x0117
|
|
||||||
MinSampleValue = 0x0118
|
|
||||||
MaxSampleValue = 0x0119
|
|
||||||
XResolution = 0x011A
|
|
||||||
YResolution = 0x011B
|
|
||||||
PlanarConfiguration = 0x011C
|
|
||||||
PageName = 0x011D
|
|
||||||
FreeOffsets = 0x0120
|
|
||||||
FreeByteCounts = 0x0121
|
|
||||||
GrayResponseUnit = 0x0122
|
|
||||||
GrayResponseCurve = 0x0123
|
|
||||||
T4Options = 0x0124
|
|
||||||
T6Options = 0x0125
|
|
||||||
ResolutionUnit = 0x0128
|
|
||||||
PageNumber = 0x0129
|
|
||||||
TransferFunction = 0x012D
|
|
||||||
Software = 0x0131
|
|
||||||
DateTime = 0x0132
|
|
||||||
Artist = 0x013B
|
|
||||||
HostComputer = 0x013C
|
|
||||||
Predictor = 0x013D
|
|
||||||
WhitePoint = 0x013E
|
|
||||||
PrimaryChromaticities = 0x013F
|
|
||||||
ColorMap = 0x0140
|
|
||||||
HalftoneHints = 0x0141
|
|
||||||
TileWidth = 0x0142
|
|
||||||
TileLength = 0x0143
|
|
||||||
TileOffsets = 0x0144
|
|
||||||
TileByteCounts = 0x0145
|
|
||||||
SubIFDs = 0x014A
|
|
||||||
InkSet = 0x014C
|
|
||||||
InkNames = 0x014D
|
|
||||||
NumberOfInks = 0x014E
|
|
||||||
DotRange = 0x0150
|
|
||||||
TargetPrinter = 0x0151
|
|
||||||
ExtraSamples = 0x0152
|
|
||||||
SampleFormat = 0x0153
|
|
||||||
SMinSampleValue = 0x0154
|
|
||||||
SMaxSampleValue = 0x0155
|
|
||||||
TransferRange = 0x0156
|
|
||||||
ClipPath = 0x0157
|
|
||||||
XClipPathUnits = 0x0158
|
|
||||||
YClipPathUnits = 0x0159
|
|
||||||
Indexed = 0x015A
|
|
||||||
JPEGTables = 0x015B
|
|
||||||
OPIProxy = 0x015F
|
|
||||||
JPEGProc = 0x0200
|
|
||||||
JpegIFOffset = 0x0201
|
|
||||||
JpegIFByteCount = 0x0202
|
|
||||||
JpegRestartInterval = 0x0203
|
|
||||||
JpegLosslessPredictors = 0x0205
|
|
||||||
JpegPointTransforms = 0x0206
|
|
||||||
JpegQTables = 0x0207
|
|
||||||
JpegDCTables = 0x0208
|
|
||||||
JpegACTables = 0x0209
|
|
||||||
YCbCrCoefficients = 0x0211
|
|
||||||
YCbCrSubSampling = 0x0212
|
|
||||||
YCbCrPositioning = 0x0213
|
|
||||||
ReferenceBlackWhite = 0x0214
|
|
||||||
XMLPacket = 0x02BC
|
|
||||||
RelatedImageFileFormat = 0x1000
|
|
||||||
RelatedImageWidth = 0x1001
|
|
||||||
RelatedImageLength = 0x1002
|
|
||||||
Rating = 0x4746
|
|
||||||
RatingPercent = 0x4749
|
|
||||||
ImageID = 0x800D
|
|
||||||
CFARepeatPatternDim = 0x828D
|
|
||||||
BatteryLevel = 0x828F
|
|
||||||
Copyright = 0x8298
|
|
||||||
ExposureTime = 0x829A
|
|
||||||
FNumber = 0x829D
|
|
||||||
IPTCNAA = 0x83BB
|
|
||||||
ImageResources = 0x8649
|
|
||||||
ExifOffset = 0x8769
|
|
||||||
InterColorProfile = 0x8773
|
|
||||||
ExposureProgram = 0x8822
|
|
||||||
SpectralSensitivity = 0x8824
|
|
||||||
GPSInfo = 0x8825
|
|
||||||
ISOSpeedRatings = 0x8827
|
|
||||||
OECF = 0x8828
|
|
||||||
Interlace = 0x8829
|
|
||||||
TimeZoneOffset = 0x882A
|
|
||||||
SelfTimerMode = 0x882B
|
|
||||||
SensitivityType = 0x8830
|
|
||||||
StandardOutputSensitivity = 0x8831
|
|
||||||
RecommendedExposureIndex = 0x8832
|
|
||||||
ISOSpeed = 0x8833
|
|
||||||
ISOSpeedLatitudeyyy = 0x8834
|
|
||||||
ISOSpeedLatitudezzz = 0x8835
|
|
||||||
ExifVersion = 0x9000
|
|
||||||
DateTimeOriginal = 0x9003
|
|
||||||
DateTimeDigitized = 0x9004
|
|
||||||
OffsetTime = 0x9010
|
|
||||||
OffsetTimeOriginal = 0x9011
|
|
||||||
OffsetTimeDigitized = 0x9012
|
|
||||||
ComponentsConfiguration = 0x9101
|
|
||||||
CompressedBitsPerPixel = 0x9102
|
|
||||||
ShutterSpeedValue = 0x9201
|
|
||||||
ApertureValue = 0x9202
|
|
||||||
BrightnessValue = 0x9203
|
|
||||||
ExposureBiasValue = 0x9204
|
|
||||||
MaxApertureValue = 0x9205
|
|
||||||
SubjectDistance = 0x9206
|
|
||||||
MeteringMode = 0x9207
|
|
||||||
LightSource = 0x9208
|
|
||||||
Flash = 0x9209
|
|
||||||
FocalLength = 0x920A
|
|
||||||
Noise = 0x920D
|
|
||||||
ImageNumber = 0x9211
|
|
||||||
SecurityClassification = 0x9212
|
|
||||||
ImageHistory = 0x9213
|
|
||||||
TIFFEPStandardID = 0x9216
|
|
||||||
MakerNote = 0x927C
|
|
||||||
UserComment = 0x9286
|
|
||||||
SubsecTime = 0x9290
|
|
||||||
SubsecTimeOriginal = 0x9291
|
|
||||||
SubsecTimeDigitized = 0x9292
|
|
||||||
AmbientTemperature = 0x9400
|
|
||||||
Humidity = 0x9401
|
|
||||||
Pressure = 0x9402
|
|
||||||
WaterDepth = 0x9403
|
|
||||||
Acceleration = 0x9404
|
|
||||||
CameraElevationAngle = 0x9405
|
|
||||||
XPTitle = 0x9C9B
|
|
||||||
XPComment = 0x9C9C
|
|
||||||
XPAuthor = 0x9C9D
|
|
||||||
XPKeywords = 0x9C9E
|
|
||||||
XPSubject = 0x9C9F
|
|
||||||
FlashPixVersion = 0xA000
|
|
||||||
ColorSpace = 0xA001
|
|
||||||
ExifImageWidth = 0xA002
|
|
||||||
ExifImageHeight = 0xA003
|
|
||||||
RelatedSoundFile = 0xA004
|
|
||||||
ExifInteroperabilityOffset = 0xA005
|
|
||||||
FlashEnergy = 0xA20B
|
|
||||||
SpatialFrequencyResponse = 0xA20C
|
|
||||||
FocalPlaneXResolution = 0xA20E
|
|
||||||
FocalPlaneYResolution = 0xA20F
|
|
||||||
FocalPlaneResolutionUnit = 0xA210
|
|
||||||
SubjectLocation = 0xA214
|
|
||||||
ExposureIndex = 0xA215
|
|
||||||
SensingMethod = 0xA217
|
|
||||||
FileSource = 0xA300
|
|
||||||
SceneType = 0xA301
|
|
||||||
CFAPattern = 0xA302
|
|
||||||
CustomRendered = 0xA401
|
|
||||||
ExposureMode = 0xA402
|
|
||||||
WhiteBalance = 0xA403
|
|
||||||
DigitalZoomRatio = 0xA404
|
|
||||||
FocalLengthIn35mmFilm = 0xA405
|
|
||||||
SceneCaptureType = 0xA406
|
|
||||||
GainControl = 0xA407
|
|
||||||
Contrast = 0xA408
|
|
||||||
Saturation = 0xA409
|
|
||||||
Sharpness = 0xA40A
|
|
||||||
DeviceSettingDescription = 0xA40B
|
|
||||||
SubjectDistanceRange = 0xA40C
|
|
||||||
ImageUniqueID = 0xA420
|
|
||||||
CameraOwnerName = 0xA430
|
|
||||||
BodySerialNumber = 0xA431
|
|
||||||
LensSpecification = 0xA432
|
|
||||||
LensMake = 0xA433
|
|
||||||
LensModel = 0xA434
|
|
||||||
LensSerialNumber = 0xA435
|
|
||||||
CompositeImage = 0xA460
|
|
||||||
CompositeImageCount = 0xA461
|
|
||||||
CompositeImageExposureTimes = 0xA462
|
|
||||||
Gamma = 0xA500
|
|
||||||
PrintImageMatching = 0xC4A5
|
|
||||||
DNGVersion = 0xC612
|
|
||||||
DNGBackwardVersion = 0xC613
|
|
||||||
UniqueCameraModel = 0xC614
|
|
||||||
LocalizedCameraModel = 0xC615
|
|
||||||
CFAPlaneColor = 0xC616
|
|
||||||
CFALayout = 0xC617
|
|
||||||
LinearizationTable = 0xC618
|
|
||||||
BlackLevelRepeatDim = 0xC619
|
|
||||||
BlackLevel = 0xC61A
|
|
||||||
BlackLevelDeltaH = 0xC61B
|
|
||||||
BlackLevelDeltaV = 0xC61C
|
|
||||||
WhiteLevel = 0xC61D
|
|
||||||
DefaultScale = 0xC61E
|
|
||||||
DefaultCropOrigin = 0xC61F
|
|
||||||
DefaultCropSize = 0xC620
|
|
||||||
ColorMatrix1 = 0xC621
|
|
||||||
ColorMatrix2 = 0xC622
|
|
||||||
CameraCalibration1 = 0xC623
|
|
||||||
CameraCalibration2 = 0xC624
|
|
||||||
ReductionMatrix1 = 0xC625
|
|
||||||
ReductionMatrix2 = 0xC626
|
|
||||||
AnalogBalance = 0xC627
|
|
||||||
AsShotNeutral = 0xC628
|
|
||||||
AsShotWhiteXY = 0xC629
|
|
||||||
BaselineExposure = 0xC62A
|
|
||||||
BaselineNoise = 0xC62B
|
|
||||||
BaselineSharpness = 0xC62C
|
|
||||||
BayerGreenSplit = 0xC62D
|
|
||||||
LinearResponseLimit = 0xC62E
|
|
||||||
CameraSerialNumber = 0xC62F
|
|
||||||
LensInfo = 0xC630
|
|
||||||
ChromaBlurRadius = 0xC631
|
|
||||||
AntiAliasStrength = 0xC632
|
|
||||||
ShadowScale = 0xC633
|
|
||||||
DNGPrivateData = 0xC634
|
|
||||||
MakerNoteSafety = 0xC635
|
|
||||||
CalibrationIlluminant1 = 0xC65A
|
|
||||||
CalibrationIlluminant2 = 0xC65B
|
|
||||||
BestQualityScale = 0xC65C
|
|
||||||
RawDataUniqueID = 0xC65D
|
|
||||||
OriginalRawFileName = 0xC68B
|
|
||||||
OriginalRawFileData = 0xC68C
|
|
||||||
ActiveArea = 0xC68D
|
|
||||||
MaskedAreas = 0xC68E
|
|
||||||
AsShotICCProfile = 0xC68F
|
|
||||||
AsShotPreProfileMatrix = 0xC690
|
|
||||||
CurrentICCProfile = 0xC691
|
|
||||||
CurrentPreProfileMatrix = 0xC692
|
|
||||||
ColorimetricReference = 0xC6BF
|
|
||||||
CameraCalibrationSignature = 0xC6F3
|
|
||||||
ProfileCalibrationSignature = 0xC6F4
|
|
||||||
AsShotProfileName = 0xC6F6
|
|
||||||
NoiseReductionApplied = 0xC6F7
|
|
||||||
ProfileName = 0xC6F8
|
|
||||||
ProfileHueSatMapDims = 0xC6F9
|
|
||||||
ProfileHueSatMapData1 = 0xC6FA
|
|
||||||
ProfileHueSatMapData2 = 0xC6FB
|
|
||||||
ProfileToneCurve = 0xC6FC
|
|
||||||
ProfileEmbedPolicy = 0xC6FD
|
|
||||||
ProfileCopyright = 0xC6FE
|
|
||||||
ForwardMatrix1 = 0xC714
|
|
||||||
ForwardMatrix2 = 0xC715
|
|
||||||
PreviewApplicationName = 0xC716
|
|
||||||
PreviewApplicationVersion = 0xC717
|
|
||||||
PreviewSettingsName = 0xC718
|
|
||||||
PreviewSettingsDigest = 0xC719
|
|
||||||
PreviewColorSpace = 0xC71A
|
|
||||||
PreviewDateTime = 0xC71B
|
|
||||||
RawImageDigest = 0xC71C
|
|
||||||
OriginalRawFileDigest = 0xC71D
|
|
||||||
SubTileBlockSize = 0xC71E
|
|
||||||
RowInterleaveFactor = 0xC71F
|
|
||||||
ProfileLookTableDims = 0xC725
|
|
||||||
ProfileLookTableData = 0xC726
|
|
||||||
OpcodeList1 = 0xC740
|
|
||||||
OpcodeList2 = 0xC741
|
|
||||||
OpcodeList3 = 0xC74E
|
|
||||||
NoiseProfile = 0xC761
|
|
||||||
FrameRate = 0xC764
|
|
||||||
|
|
||||||
|
|
||||||
"""Maps EXIF tags to tag names."""
|
|
||||||
TAGS = {
|
|
||||||
**{i.value: i.name for i in Base},
|
|
||||||
0x920C: "SpatialFrequencyResponse",
|
|
||||||
0x9214: "SubjectLocation",
|
|
||||||
0x9215: "ExposureIndex",
|
|
||||||
0x828E: "CFAPattern",
|
|
||||||
0x920B: "FlashEnergy",
|
|
||||||
0x9216: "TIFF/EPStandardID",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class GPS(IntEnum):
|
|
||||||
GPSVersionID = 0x00
|
|
||||||
GPSLatitudeRef = 0x01
|
|
||||||
GPSLatitude = 0x02
|
|
||||||
GPSLongitudeRef = 0x03
|
|
||||||
GPSLongitude = 0x04
|
|
||||||
GPSAltitudeRef = 0x05
|
|
||||||
GPSAltitude = 0x06
|
|
||||||
GPSTimeStamp = 0x07
|
|
||||||
GPSSatellites = 0x08
|
|
||||||
GPSStatus = 0x09
|
|
||||||
GPSMeasureMode = 0x0A
|
|
||||||
GPSDOP = 0x0B
|
|
||||||
GPSSpeedRef = 0x0C
|
|
||||||
GPSSpeed = 0x0D
|
|
||||||
GPSTrackRef = 0x0E
|
|
||||||
GPSTrack = 0x0F
|
|
||||||
GPSImgDirectionRef = 0x10
|
|
||||||
GPSImgDirection = 0x11
|
|
||||||
GPSMapDatum = 0x12
|
|
||||||
GPSDestLatitudeRef = 0x13
|
|
||||||
GPSDestLatitude = 0x14
|
|
||||||
GPSDestLongitudeRef = 0x15
|
|
||||||
GPSDestLongitude = 0x16
|
|
||||||
GPSDestBearingRef = 0x17
|
|
||||||
GPSDestBearing = 0x18
|
|
||||||
GPSDestDistanceRef = 0x19
|
|
||||||
GPSDestDistance = 0x1A
|
|
||||||
GPSProcessingMethod = 0x1B
|
|
||||||
GPSAreaInformation = 0x1C
|
|
||||||
GPSDateStamp = 0x1D
|
|
||||||
GPSDifferential = 0x1E
|
|
||||||
GPSHPositioningError = 0x1F
|
|
||||||
|
|
||||||
|
|
||||||
"""Maps EXIF GPS tags to tag names."""
|
|
||||||
GPSTAGS = {i.value: i.name for i in GPS}
|
|
||||||
|
|
||||||
|
|
||||||
class Interop(IntEnum):
|
|
||||||
InteropIndex = 0x0001
|
|
||||||
InteropVersion = 0x0002
|
|
||||||
RelatedImageFileFormat = 0x1000
|
|
||||||
RelatedImageWidth = 0x1001
|
|
||||||
RelatedImageHeight = 0x1002
|
|
||||||
|
|
||||||
|
|
||||||
class IFD(IntEnum):
|
|
||||||
Exif = 0x8769
|
|
||||||
GPSInfo = 0x8825
|
|
||||||
MakerNote = 0x927C
|
|
||||||
Makernote = 0x927C # Deprecated
|
|
||||||
Interop = 0xA005
|
|
||||||
IFD1 = -1
|
|
||||||
|
|
||||||
|
|
||||||
class LightSource(IntEnum):
|
|
||||||
Unknown = 0x00
|
|
||||||
Daylight = 0x01
|
|
||||||
Fluorescent = 0x02
|
|
||||||
Tungsten = 0x03
|
|
||||||
Flash = 0x04
|
|
||||||
Fine = 0x09
|
|
||||||
Cloudy = 0x0A
|
|
||||||
Shade = 0x0B
|
|
||||||
DaylightFluorescent = 0x0C
|
|
||||||
DayWhiteFluorescent = 0x0D
|
|
||||||
CoolWhiteFluorescent = 0x0E
|
|
||||||
WhiteFluorescent = 0x0F
|
|
||||||
StandardLightA = 0x11
|
|
||||||
StandardLightB = 0x12
|
|
||||||
StandardLightC = 0x13
|
|
||||||
D55 = 0x14
|
|
||||||
D65 = 0x15
|
|
||||||
D75 = 0x16
|
|
||||||
D50 = 0x17
|
|
||||||
ISO = 0x18
|
|
||||||
Other = 0xFF
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# FITS file handling
|
|
||||||
#
|
|
||||||
# Copyright (c) 1998-2003 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import gzip
|
|
||||||
import math
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(b"SIMPLE")
|
|
||||||
|
|
||||||
|
|
||||||
class FitsImageFile(ImageFile.ImageFile):
|
|
||||||
format = "FITS"
|
|
||||||
format_description = "FITS"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
|
|
||||||
headers: dict[bytes, bytes] = {}
|
|
||||||
header_in_progress = False
|
|
||||||
decoder_name = ""
|
|
||||||
while True:
|
|
||||||
header = self.fp.read(80)
|
|
||||||
if not header:
|
|
||||||
msg = "Truncated FITS file"
|
|
||||||
raise OSError(msg)
|
|
||||||
keyword = header[:8].strip()
|
|
||||||
if keyword in (b"SIMPLE", b"XTENSION"):
|
|
||||||
header_in_progress = True
|
|
||||||
elif headers and not header_in_progress:
|
|
||||||
# This is now a data unit
|
|
||||||
break
|
|
||||||
elif keyword == b"END":
|
|
||||||
# Seek to the end of the header unit
|
|
||||||
self.fp.seek(math.ceil(self.fp.tell() / 2880) * 2880)
|
|
||||||
if not decoder_name:
|
|
||||||
decoder_name, offset, args = self._parse_headers(headers)
|
|
||||||
|
|
||||||
header_in_progress = False
|
|
||||||
continue
|
|
||||||
|
|
||||||
if decoder_name:
|
|
||||||
# Keep going to read past the headers
|
|
||||||
continue
|
|
||||||
|
|
||||||
value = header[8:].split(b"/")[0].strip()
|
|
||||||
if value.startswith(b"="):
|
|
||||||
value = value[1:].strip()
|
|
||||||
if not headers and (not _accept(keyword) or value != b"T"):
|
|
||||||
msg = "Not a FITS file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
headers[keyword] = value
|
|
||||||
|
|
||||||
if not decoder_name:
|
|
||||||
msg = "No image data"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
offset += self.fp.tell() - 80
|
|
||||||
self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)]
|
|
||||||
|
|
||||||
def _get_size(
|
|
||||||
self, headers: dict[bytes, bytes], prefix: bytes
|
|
||||||
) -> tuple[int, int] | None:
|
|
||||||
naxis = int(headers[prefix + b"NAXIS"])
|
|
||||||
if naxis == 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if naxis == 1:
|
|
||||||
return 1, int(headers[prefix + b"NAXIS1"])
|
|
||||||
else:
|
|
||||||
return int(headers[prefix + b"NAXIS1"]), int(headers[prefix + b"NAXIS2"])
|
|
||||||
|
|
||||||
def _parse_headers(
|
|
||||||
self, headers: dict[bytes, bytes]
|
|
||||||
) -> tuple[str, int, tuple[str | int, ...]]:
|
|
||||||
prefix = b""
|
|
||||||
decoder_name = "raw"
|
|
||||||
offset = 0
|
|
||||||
if (
|
|
||||||
headers.get(b"XTENSION") == b"'BINTABLE'"
|
|
||||||
and headers.get(b"ZIMAGE") == b"T"
|
|
||||||
and headers[b"ZCMPTYPE"] == b"'GZIP_1 '"
|
|
||||||
):
|
|
||||||
no_prefix_size = self._get_size(headers, prefix) or (0, 0)
|
|
||||||
number_of_bits = int(headers[b"BITPIX"])
|
|
||||||
offset = no_prefix_size[0] * no_prefix_size[1] * (number_of_bits // 8)
|
|
||||||
|
|
||||||
prefix = b"Z"
|
|
||||||
decoder_name = "fits_gzip"
|
|
||||||
|
|
||||||
size = self._get_size(headers, prefix)
|
|
||||||
if not size:
|
|
||||||
return "", 0, ()
|
|
||||||
|
|
||||||
self._size = size
|
|
||||||
|
|
||||||
number_of_bits = int(headers[prefix + b"BITPIX"])
|
|
||||||
if number_of_bits == 8:
|
|
||||||
self._mode = "L"
|
|
||||||
elif number_of_bits == 16:
|
|
||||||
self._mode = "I;16"
|
|
||||||
elif number_of_bits == 32:
|
|
||||||
self._mode = "I"
|
|
||||||
elif number_of_bits in (-32, -64):
|
|
||||||
self._mode = "F"
|
|
||||||
|
|
||||||
args: tuple[str | int, ...]
|
|
||||||
if decoder_name == "raw":
|
|
||||||
args = (self.mode, 0, -1)
|
|
||||||
else:
|
|
||||||
args = (number_of_bits,)
|
|
||||||
return decoder_name, offset, args
|
|
||||||
|
|
||||||
|
|
||||||
class FitsGzipDecoder(ImageFile.PyDecoder):
|
|
||||||
_pulls_fd = True
|
|
||||||
|
|
||||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
|
||||||
assert self.fd is not None
|
|
||||||
with gzip.open(self.fd) as fp:
|
|
||||||
value = fp.read(self.state.xsize * self.state.ysize * 4)
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
offset = 0
|
|
||||||
number_of_bits = min(self.args[0] // 8, 4)
|
|
||||||
for y in range(self.state.ysize):
|
|
||||||
row = bytearray()
|
|
||||||
for x in range(self.state.xsize):
|
|
||||||
row += value[offset + (4 - number_of_bits) : offset + 4]
|
|
||||||
offset += 4
|
|
||||||
rows.append(row)
|
|
||||||
self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row]))
|
|
||||||
return -1, 0
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Registry
|
|
||||||
|
|
||||||
Image.register_open(FitsImageFile.format, FitsImageFile, _accept)
|
|
||||||
Image.register_decoder("fits_gzip", FitsGzipDecoder)
|
|
||||||
|
|
||||||
Image.register_extensions(FitsImageFile.format, [".fit", ".fits"])
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# FLI/FLC file handling.
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 95-09-01 fl Created
|
|
||||||
# 97-01-03 fl Fixed parser, setup decoder tile
|
|
||||||
# 98-07-15 fl Renamed offset attribute to avoid name clash
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997-98.
|
|
||||||
# Copyright (c) Fredrik Lundh 1995-97.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
from . import Image, ImageFile, ImagePalette
|
|
||||||
from ._binary import i16le as i16
|
|
||||||
from ._binary import i32le as i32
|
|
||||||
from ._binary import o8
|
|
||||||
from ._util import DeferredError
|
|
||||||
|
|
||||||
#
|
|
||||||
# decoder
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return (
|
|
||||||
len(prefix) >= 16
|
|
||||||
and i16(prefix, 4) in [0xAF11, 0xAF12]
|
|
||||||
and i16(prefix, 14) in [0, 3] # flags
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for the FLI/FLC animation format. Use the <b>seek</b>
|
|
||||||
# method to load individual frames.
|
|
||||||
|
|
||||||
|
|
||||||
class FliImageFile(ImageFile.ImageFile):
|
|
||||||
format = "FLI"
|
|
||||||
format_description = "Autodesk FLI/FLC Animation"
|
|
||||||
_close_exclusive_fp_after_loading = False
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
# HEAD
|
|
||||||
assert self.fp is not None
|
|
||||||
s = self.fp.read(128)
|
|
||||||
if not (
|
|
||||||
_accept(s)
|
|
||||||
and s[20:22] == b"\x00" * 2
|
|
||||||
and s[42:80] == b"\x00" * 38
|
|
||||||
and s[88:] == b"\x00" * 40
|
|
||||||
):
|
|
||||||
msg = "not an FLI/FLC file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
# frames
|
|
||||||
self.n_frames = i16(s, 6)
|
|
||||||
self.is_animated = self.n_frames > 1
|
|
||||||
|
|
||||||
# image characteristics
|
|
||||||
self._mode = "P"
|
|
||||||
self._size = i16(s, 8), i16(s, 10)
|
|
||||||
|
|
||||||
# animation speed
|
|
||||||
duration = i32(s, 16)
|
|
||||||
magic = i16(s, 4)
|
|
||||||
if magic == 0xAF11:
|
|
||||||
duration = (duration * 1000) // 70
|
|
||||||
self.info["duration"] = duration
|
|
||||||
|
|
||||||
# look for palette
|
|
||||||
palette = [(a, a, a) for a in range(256)]
|
|
||||||
|
|
||||||
s = self.fp.read(16)
|
|
||||||
|
|
||||||
self.__offset = 128
|
|
||||||
|
|
||||||
if i16(s, 4) == 0xF100:
|
|
||||||
# prefix chunk; ignore it
|
|
||||||
self.fp.seek(self.__offset + i32(s))
|
|
||||||
s = self.fp.read(16)
|
|
||||||
|
|
||||||
if i16(s, 4) == 0xF1FA:
|
|
||||||
# look for palette chunk
|
|
||||||
number_of_subchunks = i16(s, 6)
|
|
||||||
chunk_size: int | None = None
|
|
||||||
for _ in range(number_of_subchunks):
|
|
||||||
if chunk_size is not None:
|
|
||||||
self.fp.seek(chunk_size - 6, os.SEEK_CUR)
|
|
||||||
s = self.fp.read(6)
|
|
||||||
chunk_type = i16(s, 4)
|
|
||||||
if chunk_type in (4, 11):
|
|
||||||
self._palette(palette, 2 if chunk_type == 11 else 0)
|
|
||||||
break
|
|
||||||
chunk_size = i32(s)
|
|
||||||
if not chunk_size:
|
|
||||||
break
|
|
||||||
|
|
||||||
self.palette = ImagePalette.raw(
|
|
||||||
"RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette)
|
|
||||||
)
|
|
||||||
|
|
||||||
# set things up to decode first frame
|
|
||||||
self.__frame = -1
|
|
||||||
self._fp = self.fp
|
|
||||||
self.__rewind = self.fp.tell()
|
|
||||||
self.seek(0)
|
|
||||||
|
|
||||||
def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None:
|
|
||||||
# load palette
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
assert self.fp is not None
|
|
||||||
for e in range(i16(self.fp.read(2))):
|
|
||||||
s = self.fp.read(2)
|
|
||||||
i = i + s[0]
|
|
||||||
n = s[1]
|
|
||||||
if n == 0:
|
|
||||||
n = 256
|
|
||||||
s = self.fp.read(n * 3)
|
|
||||||
for n in range(0, len(s), 3):
|
|
||||||
r = s[n] << shift
|
|
||||||
g = s[n + 1] << shift
|
|
||||||
b = s[n + 2] << shift
|
|
||||||
palette[i] = (r, g, b)
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
def seek(self, frame: int) -> None:
|
|
||||||
if not self._seek_check(frame):
|
|
||||||
return
|
|
||||||
if frame < self.__frame:
|
|
||||||
self._seek(0)
|
|
||||||
|
|
||||||
for f in range(self.__frame + 1, frame + 1):
|
|
||||||
self._seek(f)
|
|
||||||
|
|
||||||
def _seek(self, frame: int) -> None:
|
|
||||||
if isinstance(self._fp, DeferredError):
|
|
||||||
raise self._fp.ex
|
|
||||||
if frame == 0:
|
|
||||||
self.__frame = -1
|
|
||||||
self._fp.seek(self.__rewind)
|
|
||||||
self.__offset = 128
|
|
||||||
else:
|
|
||||||
# ensure that the previous frame was loaded
|
|
||||||
self.load()
|
|
||||||
|
|
||||||
if frame != self.__frame + 1:
|
|
||||||
msg = f"cannot seek to frame {frame}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.__frame = frame
|
|
||||||
|
|
||||||
# move to next frame
|
|
||||||
self.fp = self._fp
|
|
||||||
self.fp.seek(self.__offset)
|
|
||||||
|
|
||||||
s = self.fp.read(4)
|
|
||||||
if not s:
|
|
||||||
msg = "missing frame size"
|
|
||||||
raise EOFError(msg)
|
|
||||||
|
|
||||||
framesize = i32(s)
|
|
||||||
|
|
||||||
self.decodermaxblock = framesize
|
|
||||||
self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)]
|
|
||||||
|
|
||||||
self.__offset += framesize
|
|
||||||
|
|
||||||
def tell(self) -> int:
|
|
||||||
return self.__frame
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# registry
|
|
||||||
|
|
||||||
Image.register_open(FliImageFile.format, FliImageFile, _accept)
|
|
||||||
|
|
||||||
Image.register_extensions(FliImageFile.format, [".fli", ".flc"])
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# base class for raster font file parsers
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 1997-06-05 fl created
|
|
||||||
# 1997-08-19 fl restrict image width
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-1998 by Secret Labs AB
|
|
||||||
# Copyright (c) 1997-1998 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import BinaryIO
|
|
||||||
|
|
||||||
from . import Image, ImageFont, _binary
|
|
||||||
|
|
||||||
WIDTH = 800
|
|
||||||
|
|
||||||
|
|
||||||
def puti16(
|
|
||||||
fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int]
|
|
||||||
) -> None:
|
|
||||||
"""Write network order (big-endian) 16-bit sequence"""
|
|
||||||
for v in values:
|
|
||||||
if v < 0:
|
|
||||||
v += 65536
|
|
||||||
fp.write(_binary.o16be(v))
|
|
||||||
|
|
||||||
|
|
||||||
class FontFile:
|
|
||||||
"""Base class for raster font file handlers."""
|
|
||||||
|
|
||||||
bitmap: Image.Image | None = None
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.info: dict[bytes, bytes | int] = {}
|
|
||||||
self.glyph: list[
|
|
||||||
tuple[
|
|
||||||
tuple[int, int],
|
|
||||||
tuple[int, int, int, int],
|
|
||||||
tuple[int, int, int, int],
|
|
||||||
Image.Image,
|
|
||||||
]
|
|
||||||
| None
|
|
||||||
] = [None] * 256
|
|
||||||
|
|
||||||
def __getitem__(self, ix: int) -> (
|
|
||||||
tuple[
|
|
||||||
tuple[int, int],
|
|
||||||
tuple[int, int, int, int],
|
|
||||||
tuple[int, int, int, int],
|
|
||||||
Image.Image,
|
|
||||||
]
|
|
||||||
| None
|
|
||||||
):
|
|
||||||
return self.glyph[ix]
|
|
||||||
|
|
||||||
def compile(self) -> None:
|
|
||||||
"""Create metrics and bitmap"""
|
|
||||||
|
|
||||||
if self.bitmap:
|
|
||||||
return
|
|
||||||
|
|
||||||
# create bitmap large enough to hold all data
|
|
||||||
h = w = maxwidth = 0
|
|
||||||
lines = 1
|
|
||||||
for glyph in self.glyph:
|
|
||||||
if glyph:
|
|
||||||
d, dst, src, im = glyph
|
|
||||||
h = max(h, src[3] - src[1])
|
|
||||||
w = w + (src[2] - src[0])
|
|
||||||
if w > WIDTH:
|
|
||||||
lines += 1
|
|
||||||
w = src[2] - src[0]
|
|
||||||
maxwidth = max(maxwidth, w)
|
|
||||||
|
|
||||||
xsize = maxwidth
|
|
||||||
ysize = lines * h
|
|
||||||
|
|
||||||
if xsize == 0 and ysize == 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.ysize = h
|
|
||||||
|
|
||||||
# paste glyphs into bitmap
|
|
||||||
self.bitmap = Image.new("1", (xsize, ysize))
|
|
||||||
self.metrics: list[
|
|
||||||
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]]
|
|
||||||
| None
|
|
||||||
] = [None] * 256
|
|
||||||
x = y = 0
|
|
||||||
for i in range(256):
|
|
||||||
glyph = self[i]
|
|
||||||
if glyph:
|
|
||||||
d, dst, src, im = glyph
|
|
||||||
xx = src[2] - src[0]
|
|
||||||
x0, y0 = x, y
|
|
||||||
x = x + xx
|
|
||||||
if x > WIDTH:
|
|
||||||
x, y = 0, y + h
|
|
||||||
x0, y0 = x, y
|
|
||||||
x = xx
|
|
||||||
s = src[0] + x0, src[1] + y0, src[2] + x0, src[3] + y0
|
|
||||||
self.bitmap.paste(im.crop(src), s)
|
|
||||||
self.metrics[i] = d, dst, s
|
|
||||||
|
|
||||||
def _encode_metrics(self) -> bytes:
|
|
||||||
values: list[int] = []
|
|
||||||
for i in range(256):
|
|
||||||
m = self.metrics[i]
|
|
||||||
if m:
|
|
||||||
values.extend(m[0] + m[1] + m[2])
|
|
||||||
else:
|
|
||||||
values.extend((0,) * 10)
|
|
||||||
|
|
||||||
data = bytearray()
|
|
||||||
for v in values:
|
|
||||||
if v < 0:
|
|
||||||
v += 65536
|
|
||||||
data += _binary.o16be(v)
|
|
||||||
return bytes(data)
|
|
||||||
|
|
||||||
def save(self, filename: str) -> None:
|
|
||||||
"""Save font"""
|
|
||||||
|
|
||||||
self.compile()
|
|
||||||
|
|
||||||
# font data
|
|
||||||
if not self.bitmap:
|
|
||||||
msg = "No bitmap created"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG")
|
|
||||||
|
|
||||||
# font metrics
|
|
||||||
with open(os.path.splitext(filename)[0] + ".pil", "wb") as fp:
|
|
||||||
fp.write(b"PILfont\n")
|
|
||||||
fp.write(f";;;;;;{self.ysize};\n".encode("ascii")) # HACK!!!
|
|
||||||
fp.write(b"DATA\n")
|
|
||||||
fp.write(self._encode_metrics())
|
|
||||||
|
|
||||||
def to_imagefont(self) -> ImageFont.ImageFont:
|
|
||||||
"""Convert to ImageFont"""
|
|
||||||
|
|
||||||
self.compile()
|
|
||||||
|
|
||||||
# font data
|
|
||||||
if not self.bitmap:
|
|
||||||
msg = "No bitmap created"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
imagefont = ImageFont.ImageFont()
|
|
||||||
imagefont._load(self.bitmap, self._encode_metrics())
|
|
||||||
return imagefont
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
#
|
|
||||||
# THIS IS WORK IN PROGRESS
|
|
||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# FlashPix support for PIL
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 97-01-25 fl Created (reads uncompressed RGB images only)
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1997.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import olefile
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
from ._binary import i32le as i32
|
|
||||||
|
|
||||||
# we map from colour field tuples to (mode, rawmode) descriptors
|
|
||||||
MODES = {
|
|
||||||
# opacity
|
|
||||||
(0x00007FFE,): ("A", "L"),
|
|
||||||
# monochrome
|
|
||||||
(0x00010000,): ("L", "L"),
|
|
||||||
(0x00018000, 0x00017FFE): ("RGBA", "LA"),
|
|
||||||
# photo YCC
|
|
||||||
(0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"),
|
|
||||||
(0x00028000, 0x00028001, 0x00028002, 0x00027FFE): ("RGBA", "YCCA;P"),
|
|
||||||
# standard RGB (NIFRGB)
|
|
||||||
(0x00030000, 0x00030001, 0x00030002): ("RGB", "RGB"),
|
|
||||||
(0x00038000, 0x00038001, 0x00038002, 0x00037FFE): ("RGBA", "RGBA"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(olefile.MAGIC)
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for the FlashPix images.
|
|
||||||
|
|
||||||
|
|
||||||
class FpxImageFile(ImageFile.ImageFile):
|
|
||||||
format = "FPX"
|
|
||||||
format_description = "FlashPix"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
#
|
|
||||||
# read the OLE directory and see if this is a likely
|
|
||||||
# to be a FlashPix file
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
try:
|
|
||||||
self.ole = olefile.OleFileIO(self.fp)
|
|
||||||
except OSError as e:
|
|
||||||
msg = "not an FPX file; invalid OLE file"
|
|
||||||
raise SyntaxError(msg) from e
|
|
||||||
|
|
||||||
root = self.ole.root
|
|
||||||
if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B":
|
|
||||||
msg = "not an FPX file; bad root CLSID"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self._open_index(1)
|
|
||||||
|
|
||||||
def _open_index(self, index: int = 1) -> None:
|
|
||||||
#
|
|
||||||
# get the Image Contents Property Set
|
|
||||||
|
|
||||||
prop = self.ole.getproperties(
|
|
||||||
[f"Data Object Store {index:06d}", "\005Image Contents"]
|
|
||||||
)
|
|
||||||
|
|
||||||
# size (highest resolution)
|
|
||||||
|
|
||||||
assert isinstance(prop[0x1000002], int)
|
|
||||||
assert isinstance(prop[0x1000003], int)
|
|
||||||
self._size = prop[0x1000002], prop[0x1000003]
|
|
||||||
|
|
||||||
size = max(self.size)
|
|
||||||
i = 1
|
|
||||||
while size > 64:
|
|
||||||
size = size // 2
|
|
||||||
i += 1
|
|
||||||
self.maxid = i - 1
|
|
||||||
|
|
||||||
# mode. instead of using a single field for this, flashpix
|
|
||||||
# requires you to specify the mode for each channel in each
|
|
||||||
# resolution subimage, and leaves it to the decoder to make
|
|
||||||
# sure that they all match. for now, we'll cheat and assume
|
|
||||||
# that this is always the case.
|
|
||||||
|
|
||||||
id = self.maxid << 16
|
|
||||||
|
|
||||||
s = prop[0x2000002 | id]
|
|
||||||
|
|
||||||
if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4:
|
|
||||||
msg = "Invalid number of bands"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
# note: for now, we ignore the "uncalibrated" flag
|
|
||||||
colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands))
|
|
||||||
|
|
||||||
self._mode, self.rawmode = MODES[colors]
|
|
||||||
|
|
||||||
# load JPEG tables, if any
|
|
||||||
self.jpeg = {}
|
|
||||||
for i in range(256):
|
|
||||||
id = 0x3000001 | (i << 16)
|
|
||||||
if id in prop:
|
|
||||||
self.jpeg[i] = prop[id]
|
|
||||||
|
|
||||||
self._open_subimage(1, self.maxid)
|
|
||||||
|
|
||||||
def _open_subimage(self, index: int = 1, subimage: int = 0) -> None:
|
|
||||||
#
|
|
||||||
# setup tile descriptors for a given subimage
|
|
||||||
|
|
||||||
stream = [
|
|
||||||
f"Data Object Store {index:06d}",
|
|
||||||
f"Resolution {subimage:04d}",
|
|
||||||
"Subimage 0000 Header",
|
|
||||||
]
|
|
||||||
|
|
||||||
fp = self.ole.openstream(stream)
|
|
||||||
|
|
||||||
# skip prefix
|
|
||||||
fp.read(28)
|
|
||||||
|
|
||||||
# header stream
|
|
||||||
s = fp.read(36)
|
|
||||||
|
|
||||||
size = i32(s, 4), i32(s, 8)
|
|
||||||
# tilecount = i32(s, 12)
|
|
||||||
xtile, ytile = i32(s, 16), i32(s, 20)
|
|
||||||
# channels = i32(s, 24)
|
|
||||||
offset = i32(s, 28)
|
|
||||||
length = i32(s, 32)
|
|
||||||
|
|
||||||
if size != self.size:
|
|
||||||
msg = "subimage mismatch"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
# get tile descriptors
|
|
||||||
fp.seek(28 + offset)
|
|
||||||
s = fp.read(i32(s, 12) * length)
|
|
||||||
|
|
||||||
x = y = 0
|
|
||||||
xsize, ysize = size
|
|
||||||
self.tile = []
|
|
||||||
|
|
||||||
for i in range(0, len(s), length):
|
|
||||||
x1 = min(xsize, x + xtile)
|
|
||||||
y1 = min(ysize, y + ytile)
|
|
||||||
|
|
||||||
compression = i32(s, i + 8)
|
|
||||||
|
|
||||||
if compression == 0:
|
|
||||||
self.tile.append(
|
|
||||||
ImageFile._Tile(
|
|
||||||
"raw",
|
|
||||||
(x, y, x1, y1),
|
|
||||||
i32(s, i) + 28,
|
|
||||||
self.rawmode,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif compression == 1:
|
|
||||||
# FIXME: the fill decoder is not implemented
|
|
||||||
self.tile.append(
|
|
||||||
ImageFile._Tile(
|
|
||||||
"fill",
|
|
||||||
(x, y, x1, y1),
|
|
||||||
i32(s, i) + 28,
|
|
||||||
(self.rawmode, s[12:16]),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
elif compression == 2:
|
|
||||||
internal_color_conversion = s[14]
|
|
||||||
jpeg_tables = s[15]
|
|
||||||
rawmode = self.rawmode
|
|
||||||
|
|
||||||
if internal_color_conversion:
|
|
||||||
# The image is stored as usual (usually YCbCr).
|
|
||||||
if rawmode == "RGBA":
|
|
||||||
# For "RGBA", data is stored as YCbCrA based on
|
|
||||||
# negative RGB. The following trick works around
|
|
||||||
# this problem :
|
|
||||||
jpegmode, rawmode = "YCbCrK", "CMYK"
|
|
||||||
else:
|
|
||||||
jpegmode = None # let the decoder decide
|
|
||||||
|
|
||||||
else:
|
|
||||||
# The image is stored as defined by rawmode
|
|
||||||
jpegmode = rawmode
|
|
||||||
|
|
||||||
self.tile.append(
|
|
||||||
ImageFile._Tile(
|
|
||||||
"jpeg",
|
|
||||||
(x, y, x1, y1),
|
|
||||||
i32(s, i) + 28,
|
|
||||||
(rawmode, jpegmode),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# FIXME: jpeg tables are tile dependent; the prefix
|
|
||||||
# data must be placed in the tile descriptor itself!
|
|
||||||
|
|
||||||
if jpeg_tables:
|
|
||||||
self.tile_prefix = self.jpeg[jpeg_tables]
|
|
||||||
|
|
||||||
else:
|
|
||||||
msg = "unknown/invalid compression"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
x += xtile
|
|
||||||
if x >= xsize:
|
|
||||||
x, y = 0, y + ytile
|
|
||||||
if y >= ysize:
|
|
||||||
break # isn't really required
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
self.stream = stream
|
|
||||||
self._fp = self.fp
|
|
||||||
self.fp = None
|
|
||||||
|
|
||||||
def load(self) -> Image.core.PixelAccess | None:
|
|
||||||
if not self.fp:
|
|
||||||
self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"])
|
|
||||||
|
|
||||||
return ImageFile.ImageFile.load(self)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.ole.close()
|
|
||||||
super().close()
|
|
||||||
|
|
||||||
def __exit__(self, *args: object) -> None:
|
|
||||||
self.ole.close()
|
|
||||||
super().__exit__()
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(FpxImageFile.format, FpxImageFile, _accept)
|
|
||||||
|
|
||||||
Image.register_extension(FpxImageFile.format, ".fpx")
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
"""
|
|
||||||
A Pillow loader for .ftc and .ftu files (FTEX)
|
|
||||||
Jerome Leclanche <jerome@leclan.ch>
|
|
||||||
|
|
||||||
The contents of this file are hereby released in the public domain (CC0)
|
|
||||||
Full text of the CC0 license:
|
|
||||||
https://creativecommons.org/publicdomain/zero/1.0/
|
|
||||||
|
|
||||||
Independence War 2: Edge Of Chaos - Texture File Format - 16 October 2001
|
|
||||||
|
|
||||||
The textures used for 3D objects in Independence War 2: Edge Of Chaos are in a
|
|
||||||
packed custom format called FTEX. This file format uses file extensions FTC
|
|
||||||
and FTU.
|
|
||||||
* FTC files are compressed textures (using standard texture compression).
|
|
||||||
* FTU files are not compressed.
|
|
||||||
Texture File Format
|
|
||||||
The FTC and FTU texture files both use the same format. This
|
|
||||||
has the following structure:
|
|
||||||
{header}
|
|
||||||
{format_directory}
|
|
||||||
{data}
|
|
||||||
Where:
|
|
||||||
{header} = {
|
|
||||||
u32:magic,
|
|
||||||
u32:version,
|
|
||||||
u32:width,
|
|
||||||
u32:height,
|
|
||||||
u32:mipmap_count,
|
|
||||||
u32:format_count
|
|
||||||
}
|
|
||||||
|
|
||||||
* The "magic" number is "FTEX".
|
|
||||||
* "width" and "height" are the dimensions of the texture.
|
|
||||||
* "mipmap_count" is the number of mipmaps in the texture.
|
|
||||||
* "format_count" is the number of texture formats (different versions of the
|
|
||||||
same texture) in this file.
|
|
||||||
|
|
||||||
{format_directory} = format_count * { u32:format, u32:where }
|
|
||||||
|
|
||||||
The format value is 0 for DXT1 compressed textures and 1 for 24-bit RGB
|
|
||||||
uncompressed textures.
|
|
||||||
The texture data for a format starts at the position "where" in the file.
|
|
||||||
|
|
||||||
Each set of texture data in the file has the following structure:
|
|
||||||
{data} = format_count * { u32:mipmap_size, mipmap_size * { u8 } }
|
|
||||||
* "mipmap_size" is the number of bytes in that mip level. For compressed
|
|
||||||
textures this is the size of the texture data compressed with DXT1. For 24 bit
|
|
||||||
uncompressed textures, this is 3 * width * height. Following this are the image
|
|
||||||
bytes for that mipmap level.
|
|
||||||
|
|
||||||
Note: All data is stored in little-Endian (Intel) byte order.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
from enum import IntEnum
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
|
|
||||||
MAGIC = b"FTEX"
|
|
||||||
|
|
||||||
|
|
||||||
class Format(IntEnum):
|
|
||||||
DXT1 = 0
|
|
||||||
UNCOMPRESSED = 1
|
|
||||||
|
|
||||||
|
|
||||||
class FtexImageFile(ImageFile.ImageFile):
|
|
||||||
format = "FTEX"
|
|
||||||
format_description = "Texture File Format (IW2:EOC)"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
if not _accept(self.fp.read(4)):
|
|
||||||
msg = "not an FTEX file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
struct.unpack("<i", self.fp.read(4)) # version
|
|
||||||
self._size = struct.unpack("<2i", self.fp.read(8))
|
|
||||||
mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8))
|
|
||||||
|
|
||||||
# Only support single-format files.
|
|
||||||
# I don't know of any multi-format file.
|
|
||||||
assert format_count == 1
|
|
||||||
|
|
||||||
format, where = struct.unpack("<2i", self.fp.read(8))
|
|
||||||
self.fp.seek(where)
|
|
||||||
(mipmap_size,) = struct.unpack("<i", self.fp.read(4))
|
|
||||||
|
|
||||||
data = self.fp.read(mipmap_size)
|
|
||||||
|
|
||||||
if format == Format.DXT1:
|
|
||||||
self._mode = "RGBA"
|
|
||||||
self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
|
|
||||||
elif format == Format.UNCOMPRESSED:
|
|
||||||
self._mode = "RGB"
|
|
||||||
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")]
|
|
||||||
else:
|
|
||||||
msg = f"Invalid texture compression format: {repr(format)}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
self.fp.close()
|
|
||||||
self.fp = BytesIO(data)
|
|
||||||
|
|
||||||
def load_seek(self, pos: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(MAGIC)
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(FtexImageFile.format, FtexImageFile, _accept)
|
|
||||||
Image.register_extensions(FtexImageFile.format, [".ftc", ".ftu"])
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
#
|
|
||||||
# load a GIMP brush file
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 96-03-14 fl Created
|
|
||||||
# 16-01-08 es Version 2
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1996.
|
|
||||||
# Copyright (c) Eric Soroos 2016.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# See https://github.com/GNOME/gimp/blob/mainline/devel-docs/gbr.txt for
|
|
||||||
# format documentation.
|
|
||||||
#
|
|
||||||
# This code Interprets version 1 and 2 .gbr files.
|
|
||||||
# Version 1 files are obsolete, and should not be used for new
|
|
||||||
# brushes.
|
|
||||||
# Version 2 files are saved by GIMP v2.8 (at least)
|
|
||||||
# Version 3 files have a format specifier of 18 for 16bit floats in
|
|
||||||
# the color depth field. This is currently unsupported by Pillow.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
from ._binary import i32be as i32
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return len(prefix) >= 8 and i32(prefix, 0) >= 20 and i32(prefix, 4) in (1, 2)
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for the GIMP brush format.
|
|
||||||
|
|
||||||
|
|
||||||
class GbrImageFile(ImageFile.ImageFile):
|
|
||||||
format = "GBR"
|
|
||||||
format_description = "GIMP brush file"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
header_size = i32(self.fp.read(4))
|
|
||||||
if header_size < 20:
|
|
||||||
msg = "not a GIMP brush"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
version = i32(self.fp.read(4))
|
|
||||||
if version not in (1, 2):
|
|
||||||
msg = f"Unsupported GIMP brush version: {version}"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
width = i32(self.fp.read(4))
|
|
||||||
height = i32(self.fp.read(4))
|
|
||||||
color_depth = i32(self.fp.read(4))
|
|
||||||
if width == 0 or height == 0:
|
|
||||||
msg = "not a GIMP brush"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
if color_depth not in (1, 4):
|
|
||||||
msg = f"Unsupported GIMP brush color depth: {color_depth}"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
if version == 1:
|
|
||||||
comment_length = header_size - 20
|
|
||||||
else:
|
|
||||||
comment_length = header_size - 28
|
|
||||||
magic_number = self.fp.read(4)
|
|
||||||
if magic_number != b"GIMP":
|
|
||||||
msg = "not a GIMP brush, bad magic number"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
self.info["spacing"] = i32(self.fp.read(4))
|
|
||||||
|
|
||||||
self.info["comment"] = self.fp.read(comment_length)[:-1]
|
|
||||||
|
|
||||||
if color_depth == 1:
|
|
||||||
self._mode = "L"
|
|
||||||
else:
|
|
||||||
self._mode = "RGBA"
|
|
||||||
|
|
||||||
self._size = width, height
|
|
||||||
|
|
||||||
# Image might not be small
|
|
||||||
Image._decompression_bomb_check(self.size)
|
|
||||||
|
|
||||||
# Data is an uncompressed block of w * h * bytes/pixel
|
|
||||||
self._data_size = width * height * color_depth
|
|
||||||
|
|
||||||
def load(self) -> Image.core.PixelAccess | None:
|
|
||||||
if self._im is None:
|
|
||||||
assert self.fp is not None
|
|
||||||
self.im = Image.core.new(self.mode, self.size)
|
|
||||||
self.frombytes(self.fp.read(self._data_size))
|
|
||||||
return Image.Image.load(self)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# registry
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(GbrImageFile.format, GbrImageFile, _accept)
|
|
||||||
Image.register_extension(GbrImageFile.format, ".gbr")
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# GD file handling
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1996-04-12 fl Created
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997 by Secret Labs AB.
|
|
||||||
# Copyright (c) 1996 by Fredrik Lundh.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
.. note::
|
|
||||||
This format cannot be automatically recognized, so the
|
|
||||||
class is not registered for use with :py:func:`PIL.Image.open()`. To open a
|
|
||||||
gd file, use the :py:func:`PIL.GdImageFile.open()` function instead.
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
THE GD FORMAT IS NOT DESIGNED FOR DATA INTERCHANGE. This
|
|
||||||
implementation is provided for convenience and demonstrational
|
|
||||||
purposes only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import ImageFile, ImagePalette, UnidentifiedImageError
|
|
||||||
from ._binary import i16be as i16
|
|
||||||
from ._binary import i32be as i32
|
|
||||||
from ._typing import StrOrBytesPath
|
|
||||||
|
|
||||||
|
|
||||||
class GdImageFile(ImageFile.ImageFile):
|
|
||||||
"""
|
|
||||||
Image plugin for the GD uncompressed format. Note that this format
|
|
||||||
is not supported by the standard :py:func:`PIL.Image.open()` function. To use
|
|
||||||
this plugin, you have to import the :py:mod:`PIL.GdImageFile` module and
|
|
||||||
use the :py:func:`PIL.GdImageFile.open()` function.
|
|
||||||
"""
|
|
||||||
|
|
||||||
format = "GD"
|
|
||||||
format_description = "GD uncompressed images"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
# Header
|
|
||||||
assert self.fp is not None
|
|
||||||
|
|
||||||
s = self.fp.read(1037)
|
|
||||||
|
|
||||||
if i16(s) not in [65534, 65535]:
|
|
||||||
msg = "Not a valid GD 2.x .gd file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self._mode = "P"
|
|
||||||
self._size = i16(s, 2), i16(s, 4)
|
|
||||||
|
|
||||||
true_color = s[6]
|
|
||||||
true_color_offset = 2 if true_color else 0
|
|
||||||
|
|
||||||
# transparency index
|
|
||||||
tindex = i32(s, 7 + true_color_offset)
|
|
||||||
if tindex < 256:
|
|
||||||
self.info["transparency"] = tindex
|
|
||||||
|
|
||||||
self.palette = ImagePalette.raw(
|
|
||||||
"RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4]
|
|
||||||
)
|
|
||||||
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile(
|
|
||||||
"raw",
|
|
||||||
(0, 0) + self.size,
|
|
||||||
7 + true_color_offset + 6 + 256 * 4,
|
|
||||||
"L",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile:
|
|
||||||
"""
|
|
||||||
Load texture from a GD image file.
|
|
||||||
|
|
||||||
:param fp: GD file name, or an opened file handle.
|
|
||||||
:param mode: Optional mode. In this version, if the mode argument
|
|
||||||
is given, it must be "r".
|
|
||||||
:returns: An image instance.
|
|
||||||
:raises OSError: If the image could not be read.
|
|
||||||
"""
|
|
||||||
if mode != "r":
|
|
||||||
msg = "bad mode"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
try:
|
|
||||||
return GdImageFile(fp)
|
|
||||||
except SyntaxError as e:
|
|
||||||
msg = "cannot identify this image file"
|
|
||||||
raise UnidentifiedImageError(msg) from e
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +0,0 @@
|
|||||||
#
|
|
||||||
# Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# stuff to read (and render) GIMP gradient files
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 97-08-23 fl Created
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1997.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
|
|
||||||
"""
|
|
||||||
Stuff to translate curve segments to palette values (derived from
|
|
||||||
the corresponding code in GIMP, written by Federico Mena Quintero.
|
|
||||||
See the GIMP distribution for more information.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from math import log, pi, sin, sqrt
|
|
||||||
|
|
||||||
from ._binary import o8
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
EPSILON = 1e-10
|
|
||||||
"""""" # Enable auto-doc for data member
|
|
||||||
|
|
||||||
|
|
||||||
def linear(middle: float, pos: float) -> float:
|
|
||||||
if pos <= middle:
|
|
||||||
if middle < EPSILON:
|
|
||||||
return 0.0
|
|
||||||
else:
|
|
||||||
return 0.5 * pos / middle
|
|
||||||
else:
|
|
||||||
pos = pos - middle
|
|
||||||
middle = 1.0 - middle
|
|
||||||
if middle < EPSILON:
|
|
||||||
return 1.0
|
|
||||||
else:
|
|
||||||
return 0.5 + 0.5 * pos / middle
|
|
||||||
|
|
||||||
|
|
||||||
def curved(middle: float, pos: float) -> float:
|
|
||||||
return pos ** (log(0.5) / log(max(middle, EPSILON)))
|
|
||||||
|
|
||||||
|
|
||||||
def sine(middle: float, pos: float) -> float:
|
|
||||||
return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def sphere_increasing(middle: float, pos: float) -> float:
|
|
||||||
return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2)
|
|
||||||
|
|
||||||
|
|
||||||
def sphere_decreasing(middle: float, pos: float) -> float:
|
|
||||||
return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2)
|
|
||||||
|
|
||||||
|
|
||||||
SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing]
|
|
||||||
"""""" # Enable auto-doc for data member
|
|
||||||
|
|
||||||
|
|
||||||
class GradientFile:
|
|
||||||
gradient: (
|
|
||||||
list[
|
|
||||||
tuple[
|
|
||||||
float,
|
|
||||||
float,
|
|
||||||
float,
|
|
||||||
list[float],
|
|
||||||
list[float],
|
|
||||||
Callable[[float, float], float],
|
|
||||||
]
|
|
||||||
]
|
|
||||||
| None
|
|
||||||
) = None
|
|
||||||
|
|
||||||
def getpalette(self, entries: int = 256) -> tuple[bytes, str]:
|
|
||||||
assert self.gradient is not None
|
|
||||||
palette = []
|
|
||||||
|
|
||||||
ix = 0
|
|
||||||
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
|
|
||||||
|
|
||||||
for i in range(entries):
|
|
||||||
x = i / (entries - 1)
|
|
||||||
|
|
||||||
while x1 < x:
|
|
||||||
ix += 1
|
|
||||||
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
|
|
||||||
|
|
||||||
w = x1 - x0
|
|
||||||
|
|
||||||
if w < EPSILON:
|
|
||||||
scale = segment(0.5, 0.5)
|
|
||||||
else:
|
|
||||||
scale = segment((xm - x0) / w, (x - x0) / w)
|
|
||||||
|
|
||||||
# expand to RGBA
|
|
||||||
r = o8(int(255 * ((rgb1[0] - rgb0[0]) * scale + rgb0[0]) + 0.5))
|
|
||||||
g = o8(int(255 * ((rgb1[1] - rgb0[1]) * scale + rgb0[1]) + 0.5))
|
|
||||||
b = o8(int(255 * ((rgb1[2] - rgb0[2]) * scale + rgb0[2]) + 0.5))
|
|
||||||
a = o8(int(255 * ((rgb1[3] - rgb0[3]) * scale + rgb0[3]) + 0.5))
|
|
||||||
|
|
||||||
# add to palette
|
|
||||||
palette.append(r + g + b + a)
|
|
||||||
|
|
||||||
return b"".join(palette), "RGBA"
|
|
||||||
|
|
||||||
|
|
||||||
class GimpGradientFile(GradientFile):
|
|
||||||
"""File handler for GIMP's gradient format."""
|
|
||||||
|
|
||||||
def __init__(self, fp: IO[bytes]) -> None:
|
|
||||||
if not fp.readline().startswith(b"GIMP Gradient"):
|
|
||||||
msg = "not a GIMP gradient file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
line = fp.readline()
|
|
||||||
|
|
||||||
# GIMP 1.2 gradient files don't contain a name, but GIMP 1.3 files do
|
|
||||||
if line.startswith(b"Name: "):
|
|
||||||
line = fp.readline().strip()
|
|
||||||
|
|
||||||
count = int(line)
|
|
||||||
|
|
||||||
self.gradient = []
|
|
||||||
|
|
||||||
for i in range(count):
|
|
||||||
s = fp.readline().split()
|
|
||||||
w = [float(x) for x in s[:11]]
|
|
||||||
|
|
||||||
x0, x1 = w[0], w[2]
|
|
||||||
xm = w[1]
|
|
||||||
rgb0 = w[3:7]
|
|
||||||
rgb1 = w[7:11]
|
|
||||||
|
|
||||||
segment = SEGMENTS[int(s[11])]
|
|
||||||
cspace = int(s[12])
|
|
||||||
|
|
||||||
if cspace != 0:
|
|
||||||
msg = "cannot handle HSV colour space"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
self.gradient.append((x0, x1, xm, rgb0, rgb1, segment))
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
#
|
|
||||||
# Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# stuff to read GIMP palette files
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1997-08-23 fl Created
|
|
||||||
# 2004-09-07 fl Support GIMP 2.0 palette files.
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997-2004. All rights reserved.
|
|
||||||
# Copyright (c) Fredrik Lundh 1997-2004.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
|
|
||||||
class GimpPaletteFile:
|
|
||||||
"""File handler for GIMP's palette format."""
|
|
||||||
|
|
||||||
rawmode = "RGB"
|
|
||||||
|
|
||||||
def _read(self, fp: IO[bytes], limit: bool = True) -> None:
|
|
||||||
if not fp.readline().startswith(b"GIMP Palette"):
|
|
||||||
msg = "not a GIMP palette file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
palette: list[int] = []
|
|
||||||
i = 0
|
|
||||||
while True:
|
|
||||||
if limit and i == 256 + 3:
|
|
||||||
break
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
s = fp.readline()
|
|
||||||
if not s:
|
|
||||||
break
|
|
||||||
|
|
||||||
# skip fields and comment lines
|
|
||||||
if re.match(rb"\w+:|#", s):
|
|
||||||
continue
|
|
||||||
if limit and len(s) > 100:
|
|
||||||
msg = "bad palette file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
v = s.split(maxsplit=3)
|
|
||||||
if len(v) < 3:
|
|
||||||
msg = "bad palette entry"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
palette += (int(v[i]) for i in range(3))
|
|
||||||
if limit and len(palette) == 768:
|
|
||||||
break
|
|
||||||
|
|
||||||
self.palette = bytes(palette)
|
|
||||||
|
|
||||||
def __init__(self, fp: IO[bytes]) -> None:
|
|
||||||
self._read(fp)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def frombytes(cls, data: bytes) -> GimpPaletteFile:
|
|
||||||
self = cls.__new__(cls)
|
|
||||||
self._read(BytesIO(data), False)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def getpalette(self) -> tuple[bytes, str]:
|
|
||||||
return self.palette, self.rawmode
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# GRIB stub adapter
|
|
||||||
#
|
|
||||||
# Copyright (c) 1996-2003 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
|
|
||||||
_handler = None
|
|
||||||
|
|
||||||
|
|
||||||
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
|
||||||
"""
|
|
||||||
Install application-specific GRIB image handler.
|
|
||||||
|
|
||||||
:param handler: Handler object.
|
|
||||||
"""
|
|
||||||
global _handler
|
|
||||||
_handler = handler
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Image adapter
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return len(prefix) >= 8 and prefix.startswith(b"GRIB") and prefix[7] == 1
|
|
||||||
|
|
||||||
|
|
||||||
class GribStubImageFile(ImageFile.StubImageFile):
|
|
||||||
format = "GRIB"
|
|
||||||
format_description = "GRIB"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
if not _accept(self.fp.read(8)):
|
|
||||||
msg = "Not a GRIB file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self.fp.seek(-8, os.SEEK_CUR)
|
|
||||||
|
|
||||||
# make something up
|
|
||||||
self._mode = "F"
|
|
||||||
self._size = 1, 1
|
|
||||||
|
|
||||||
def _load(self) -> ImageFile.StubHandler | None:
|
|
||||||
return _handler
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
if _handler is None or not hasattr(_handler, "save"):
|
|
||||||
msg = "GRIB save handler not installed"
|
|
||||||
raise OSError(msg)
|
|
||||||
_handler.save(im, fp, filename)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Registry
|
|
||||||
|
|
||||||
Image.register_open(GribStubImageFile.format, GribStubImageFile, _accept)
|
|
||||||
Image.register_save(GribStubImageFile.format, _save)
|
|
||||||
|
|
||||||
Image.register_extension(GribStubImageFile.format, ".grib")
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# HDF5 stub adapter
|
|
||||||
#
|
|
||||||
# Copyright (c) 2000-2003 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
|
|
||||||
_handler = None
|
|
||||||
|
|
||||||
|
|
||||||
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
|
||||||
"""
|
|
||||||
Install application-specific HDF5 image handler.
|
|
||||||
|
|
||||||
:param handler: Handler object.
|
|
||||||
"""
|
|
||||||
global _handler
|
|
||||||
_handler = handler
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Image adapter
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(b"\x89HDF\r\n\x1a\n")
|
|
||||||
|
|
||||||
|
|
||||||
class HDF5StubImageFile(ImageFile.StubImageFile):
|
|
||||||
format = "HDF5"
|
|
||||||
format_description = "HDF5"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
if not _accept(self.fp.read(8)):
|
|
||||||
msg = "Not an HDF file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self.fp.seek(-8, os.SEEK_CUR)
|
|
||||||
|
|
||||||
# make something up
|
|
||||||
self._mode = "F"
|
|
||||||
self._size = 1, 1
|
|
||||||
|
|
||||||
def _load(self) -> ImageFile.StubHandler | None:
|
|
||||||
return _handler
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
if _handler is None or not hasattr(_handler, "save"):
|
|
||||||
msg = "HDF5 save handler not installed"
|
|
||||||
raise OSError(msg)
|
|
||||||
_handler.save(im, fp, filename)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Registry
|
|
||||||
|
|
||||||
Image.register_open(HDF5StubImageFile.format, HDF5StubImageFile, _accept)
|
|
||||||
Image.register_save(HDF5StubImageFile.format, _save)
|
|
||||||
|
|
||||||
Image.register_extensions(HDF5StubImageFile.format, [".h5", ".hdf"])
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# macOS icns file decoder, based on icns.py by Bob Ippolito.
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 2004-10-09 fl Turned into a PIL plugin; removed 2.3 dependencies.
|
|
||||||
# 2020-04-04 Allow saving on all operating systems.
|
|
||||||
#
|
|
||||||
# Copyright (c) 2004 by Bob Ippolito.
|
|
||||||
# Copyright (c) 2004 by Secret Labs.
|
|
||||||
# Copyright (c) 2004 by Fredrik Lundh.
|
|
||||||
# Copyright (c) 2014 by Alastair Houghton.
|
|
||||||
# Copyright (c) 2020 by Pan Jing.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import sys
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import Image, ImageFile, PngImagePlugin, features
|
|
||||||
|
|
||||||
enable_jpeg2k = features.check_codec("jpg_2000")
|
|
||||||
if enable_jpeg2k:
|
|
||||||
from . import Jpeg2KImagePlugin
|
|
||||||
|
|
||||||
MAGIC = b"icns"
|
|
||||||
HEADERSIZE = 8
|
|
||||||
|
|
||||||
|
|
||||||
def nextheader(fobj: IO[bytes]) -> tuple[bytes, int]:
|
|
||||||
return struct.unpack(">4sI", fobj.read(HEADERSIZE))
|
|
||||||
|
|
||||||
|
|
||||||
def read_32t(
|
|
||||||
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
|
|
||||||
) -> dict[str, Image.Image]:
|
|
||||||
# The 128x128 icon seems to have an extra header for some reason.
|
|
||||||
start, length = start_length
|
|
||||||
fobj.seek(start)
|
|
||||||
sig = fobj.read(4)
|
|
||||||
if sig != b"\x00\x00\x00\x00":
|
|
||||||
msg = "Unknown signature, expecting 0x00000000"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
return read_32(fobj, (start + 4, length - 4), size)
|
|
||||||
|
|
||||||
|
|
||||||
def read_32(
|
|
||||||
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
|
|
||||||
) -> dict[str, Image.Image]:
|
|
||||||
"""
|
|
||||||
Read a 32bit RGB icon resource. Seems to be either uncompressed or
|
|
||||||
an RLE packbits-like scheme.
|
|
||||||
"""
|
|
||||||
start, length = start_length
|
|
||||||
fobj.seek(start)
|
|
||||||
pixel_size = (size[0] * size[2], size[1] * size[2])
|
|
||||||
sizesq = pixel_size[0] * pixel_size[1]
|
|
||||||
if length == sizesq * 3:
|
|
||||||
# uncompressed ("RGBRGBGB")
|
|
||||||
indata = fobj.read(length)
|
|
||||||
im = Image.frombuffer("RGB", pixel_size, indata, "raw", "RGB", 0, 1)
|
|
||||||
else:
|
|
||||||
# decode image
|
|
||||||
im = Image.new("RGB", pixel_size, None)
|
|
||||||
for band_ix in range(3):
|
|
||||||
data = []
|
|
||||||
bytesleft = sizesq
|
|
||||||
while bytesleft > 0:
|
|
||||||
byte = fobj.read(1)
|
|
||||||
if not byte:
|
|
||||||
break
|
|
||||||
byte_int = byte[0]
|
|
||||||
if byte_int & 0x80:
|
|
||||||
blocksize = byte_int - 125
|
|
||||||
byte = fobj.read(1)
|
|
||||||
data.extend([byte] * blocksize)
|
|
||||||
else:
|
|
||||||
blocksize = byte_int + 1
|
|
||||||
data.append(fobj.read(blocksize))
|
|
||||||
bytesleft -= blocksize
|
|
||||||
if bytesleft <= 0:
|
|
||||||
break
|
|
||||||
if bytesleft != 0:
|
|
||||||
msg = f"Error reading channel [{repr(bytesleft)} left]"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
band = Image.frombuffer("L", pixel_size, b"".join(data), "raw", "L", 0, 1)
|
|
||||||
im.im.putband(band.im, band_ix)
|
|
||||||
return {"RGB": im}
|
|
||||||
|
|
||||||
|
|
||||||
def read_mk(
|
|
||||||
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
|
|
||||||
) -> dict[str, Image.Image]:
|
|
||||||
# Alpha masks seem to be uncompressed
|
|
||||||
start = start_length[0]
|
|
||||||
fobj.seek(start)
|
|
||||||
pixel_size = (size[0] * size[2], size[1] * size[2])
|
|
||||||
sizesq = pixel_size[0] * pixel_size[1]
|
|
||||||
band = Image.frombuffer("L", pixel_size, fobj.read(sizesq), "raw", "L", 0, 1)
|
|
||||||
return {"A": band}
|
|
||||||
|
|
||||||
|
|
||||||
def read_png_or_jpeg2000(
|
|
||||||
fobj: IO[bytes], start_length: tuple[int, int], size: tuple[int, int, int]
|
|
||||||
) -> dict[str, Image.Image]:
|
|
||||||
start, length = start_length
|
|
||||||
fobj.seek(start)
|
|
||||||
sig = fobj.read(12)
|
|
||||||
|
|
||||||
im: Image.Image
|
|
||||||
if sig.startswith(b"\x89PNG\x0d\x0a\x1a\x0a"):
|
|
||||||
fobj.seek(start)
|
|
||||||
im = PngImagePlugin.PngImageFile(fobj)
|
|
||||||
Image._decompression_bomb_check(im.size)
|
|
||||||
return {"RGBA": im}
|
|
||||||
elif (
|
|
||||||
sig.startswith((b"\xff\x4f\xff\x51", b"\x0d\x0a\x87\x0a"))
|
|
||||||
or sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a"
|
|
||||||
):
|
|
||||||
if not enable_jpeg2k:
|
|
||||||
msg = (
|
|
||||||
"Unsupported icon subimage format (rebuild PIL "
|
|
||||||
"with JPEG 2000 support to fix this)"
|
|
||||||
)
|
|
||||||
raise ValueError(msg)
|
|
||||||
# j2k, jpc or j2c
|
|
||||||
fobj.seek(start)
|
|
||||||
jp2kstream = fobj.read(length)
|
|
||||||
f = io.BytesIO(jp2kstream)
|
|
||||||
im = Jpeg2KImagePlugin.Jpeg2KImageFile(f)
|
|
||||||
Image._decompression_bomb_check(im.size)
|
|
||||||
if im.mode != "RGBA":
|
|
||||||
im = im.convert("RGBA")
|
|
||||||
return {"RGBA": im}
|
|
||||||
else:
|
|
||||||
msg = "Unsupported icon subimage format"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
class IcnsFile:
|
|
||||||
SIZES = {
|
|
||||||
(512, 512, 2): [(b"ic10", read_png_or_jpeg2000)],
|
|
||||||
(512, 512, 1): [(b"ic09", read_png_or_jpeg2000)],
|
|
||||||
(256, 256, 2): [(b"ic14", read_png_or_jpeg2000)],
|
|
||||||
(256, 256, 1): [(b"ic08", read_png_or_jpeg2000)],
|
|
||||||
(128, 128, 2): [(b"ic13", read_png_or_jpeg2000)],
|
|
||||||
(128, 128, 1): [
|
|
||||||
(b"ic07", read_png_or_jpeg2000),
|
|
||||||
(b"it32", read_32t),
|
|
||||||
(b"t8mk", read_mk),
|
|
||||||
],
|
|
||||||
(64, 64, 1): [(b"icp6", read_png_or_jpeg2000)],
|
|
||||||
(32, 32, 2): [(b"ic12", read_png_or_jpeg2000)],
|
|
||||||
(48, 48, 1): [(b"ih32", read_32), (b"h8mk", read_mk)],
|
|
||||||
(32, 32, 1): [
|
|
||||||
(b"icp5", read_png_or_jpeg2000),
|
|
||||||
(b"il32", read_32),
|
|
||||||
(b"l8mk", read_mk),
|
|
||||||
],
|
|
||||||
(16, 16, 2): [(b"ic11", read_png_or_jpeg2000)],
|
|
||||||
(16, 16, 1): [
|
|
||||||
(b"icp4", read_png_or_jpeg2000),
|
|
||||||
(b"is32", read_32),
|
|
||||||
(b"s8mk", read_mk),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, fobj: IO[bytes]) -> None:
|
|
||||||
"""
|
|
||||||
fobj is a file-like object as an icns resource
|
|
||||||
"""
|
|
||||||
# signature : (start, length)
|
|
||||||
self.dct = {}
|
|
||||||
self.fobj = fobj
|
|
||||||
sig, filesize = nextheader(fobj)
|
|
||||||
if not _accept(sig):
|
|
||||||
msg = "not an icns file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
i = HEADERSIZE
|
|
||||||
while i < filesize:
|
|
||||||
sig, blocksize = nextheader(fobj)
|
|
||||||
if blocksize <= 0:
|
|
||||||
msg = "invalid block header"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
i += HEADERSIZE
|
|
||||||
blocksize -= HEADERSIZE
|
|
||||||
self.dct[sig] = (i, blocksize)
|
|
||||||
fobj.seek(blocksize, io.SEEK_CUR)
|
|
||||||
i += blocksize
|
|
||||||
|
|
||||||
def itersizes(self) -> list[tuple[int, int, int]]:
|
|
||||||
sizes = []
|
|
||||||
for size, fmts in self.SIZES.items():
|
|
||||||
for fmt, reader in fmts:
|
|
||||||
if fmt in self.dct:
|
|
||||||
sizes.append(size)
|
|
||||||
break
|
|
||||||
return sizes
|
|
||||||
|
|
||||||
def bestsize(self) -> tuple[int, int, int]:
|
|
||||||
sizes = self.itersizes()
|
|
||||||
if not sizes:
|
|
||||||
msg = "No 32bit icon resources found"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
return max(sizes)
|
|
||||||
|
|
||||||
def dataforsize(self, size: tuple[int, int, int]) -> dict[str, Image.Image]:
|
|
||||||
"""
|
|
||||||
Get an icon resource as {channel: array}. Note that
|
|
||||||
the arrays are bottom-up like windows bitmaps and will likely
|
|
||||||
need to be flipped or transposed in some way.
|
|
||||||
"""
|
|
||||||
dct = {}
|
|
||||||
for code, reader in self.SIZES[size]:
|
|
||||||
desc = self.dct.get(code)
|
|
||||||
if desc is not None:
|
|
||||||
dct.update(reader(self.fobj, desc, size))
|
|
||||||
return dct
|
|
||||||
|
|
||||||
def getimage(
|
|
||||||
self, size: tuple[int, int] | tuple[int, int, int] | None = None
|
|
||||||
) -> Image.Image:
|
|
||||||
if size is None:
|
|
||||||
size = self.bestsize()
|
|
||||||
elif len(size) == 2:
|
|
||||||
size = (size[0], size[1], 1)
|
|
||||||
channels = self.dataforsize(size)
|
|
||||||
|
|
||||||
im = channels.get("RGBA")
|
|
||||||
if im:
|
|
||||||
return im
|
|
||||||
|
|
||||||
im = channels["RGB"].copy()
|
|
||||||
try:
|
|
||||||
im.putalpha(channels["A"])
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
return im
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for Mac OS icons.
|
|
||||||
|
|
||||||
|
|
||||||
class IcnsImageFile(ImageFile.ImageFile):
|
|
||||||
"""
|
|
||||||
PIL image support for Mac OS .icns files.
|
|
||||||
Chooses the best resolution, but will possibly load
|
|
||||||
a different size image if you mutate the size attribute
|
|
||||||
before calling 'load'.
|
|
||||||
|
|
||||||
The info dictionary has a key 'sizes' that is a list
|
|
||||||
of sizes that the icns file has.
|
|
||||||
"""
|
|
||||||
|
|
||||||
format = "ICNS"
|
|
||||||
format_description = "Mac OS icns resource"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
self.icns = IcnsFile(self.fp)
|
|
||||||
self._mode = "RGBA"
|
|
||||||
self.info["sizes"] = self.icns.itersizes()
|
|
||||||
self.best_size = self.icns.bestsize()
|
|
||||||
self.size = (
|
|
||||||
self.best_size[0] * self.best_size[2],
|
|
||||||
self.best_size[1] * self.best_size[2],
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def size(self) -> tuple[int, int]:
|
|
||||||
return self._size
|
|
||||||
|
|
||||||
@size.setter
|
|
||||||
def size(self, value: tuple[int, int]) -> None:
|
|
||||||
# Check that a matching size exists,
|
|
||||||
# or that there is a scale that would create a size that matches
|
|
||||||
for size in self.info["sizes"]:
|
|
||||||
simple_size = size[0] * size[2], size[1] * size[2]
|
|
||||||
scale = simple_size[0] // value[0]
|
|
||||||
if simple_size[1] / value[1] == scale:
|
|
||||||
self._size = value
|
|
||||||
return
|
|
||||||
msg = "This is not one of the allowed sizes of this image"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
def load(self, scale: int | None = None) -> Image.core.PixelAccess | None:
|
|
||||||
if scale is not None:
|
|
||||||
width, height = self.size[:2]
|
|
||||||
self.size = width * scale, height * scale
|
|
||||||
self.best_size = width, height, scale
|
|
||||||
|
|
||||||
px = Image.Image.load(self)
|
|
||||||
if self._im is not None and self.im.size == self.size:
|
|
||||||
# Already loaded
|
|
||||||
return px
|
|
||||||
self.load_prepare()
|
|
||||||
# This is likely NOT the best way to do it, but whatever.
|
|
||||||
im = self.icns.getimage(self.best_size)
|
|
||||||
|
|
||||||
# If this is a PNG or JPEG 2000, it won't be loaded yet
|
|
||||||
px = im.load()
|
|
||||||
|
|
||||||
self.im = im.im
|
|
||||||
self._mode = im.mode
|
|
||||||
self.size = im.size
|
|
||||||
|
|
||||||
return px
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
"""
|
|
||||||
Saves the image as a series of PNG files,
|
|
||||||
that are then combined into a .icns file.
|
|
||||||
"""
|
|
||||||
if hasattr(fp, "flush"):
|
|
||||||
fp.flush()
|
|
||||||
|
|
||||||
sizes = {
|
|
||||||
b"ic07": 128,
|
|
||||||
b"ic08": 256,
|
|
||||||
b"ic09": 512,
|
|
||||||
b"ic10": 1024,
|
|
||||||
b"ic11": 32,
|
|
||||||
b"ic12": 64,
|
|
||||||
b"ic13": 256,
|
|
||||||
b"ic14": 512,
|
|
||||||
}
|
|
||||||
provided_images = {im.width: im for im in im.encoderinfo.get("append_images", [])}
|
|
||||||
size_streams = {}
|
|
||||||
for size in set(sizes.values()):
|
|
||||||
image = (
|
|
||||||
provided_images[size]
|
|
||||||
if size in provided_images
|
|
||||||
else im.resize((size, size))
|
|
||||||
)
|
|
||||||
|
|
||||||
temp = io.BytesIO()
|
|
||||||
image.save(temp, "png")
|
|
||||||
size_streams[size] = temp.getvalue()
|
|
||||||
|
|
||||||
entries = []
|
|
||||||
for type, size in sizes.items():
|
|
||||||
stream = size_streams[size]
|
|
||||||
entries.append((type, HEADERSIZE + len(stream), stream))
|
|
||||||
|
|
||||||
# Header
|
|
||||||
fp.write(MAGIC)
|
|
||||||
file_length = HEADERSIZE # Header
|
|
||||||
file_length += HEADERSIZE + 8 * len(entries) # TOC
|
|
||||||
file_length += sum(entry[1] for entry in entries)
|
|
||||||
fp.write(struct.pack(">i", file_length))
|
|
||||||
|
|
||||||
# TOC
|
|
||||||
fp.write(b"TOC ")
|
|
||||||
fp.write(struct.pack(">i", HEADERSIZE + len(entries) * HEADERSIZE))
|
|
||||||
for entry in entries:
|
|
||||||
fp.write(entry[0])
|
|
||||||
fp.write(struct.pack(">i", entry[1]))
|
|
||||||
|
|
||||||
# Data
|
|
||||||
for entry in entries:
|
|
||||||
fp.write(entry[0])
|
|
||||||
fp.write(struct.pack(">i", entry[1]))
|
|
||||||
fp.write(entry[2])
|
|
||||||
|
|
||||||
if hasattr(fp, "flush"):
|
|
||||||
fp.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(MAGIC)
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(IcnsImageFile.format, IcnsImageFile, _accept)
|
|
||||||
Image.register_extension(IcnsImageFile.format, ".icns")
|
|
||||||
|
|
||||||
Image.register_save(IcnsImageFile.format, _save)
|
|
||||||
Image.register_mime(IcnsImageFile.format, "image/icns")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Syntax: python3 IcnsImagePlugin.py [file]")
|
|
||||||
sys.exit()
|
|
||||||
|
|
||||||
with open(sys.argv[1], "rb") as fp:
|
|
||||||
imf = IcnsImageFile(fp)
|
|
||||||
for size in imf.info["sizes"]:
|
|
||||||
width, height, scale = imf.size = size
|
|
||||||
imf.save(f"out-{width}-{height}-{scale}.png")
|
|
||||||
with Image.open(sys.argv[1]) as im:
|
|
||||||
im.save("out.png")
|
|
||||||
if sys.platform == "windows":
|
|
||||||
os.startfile("out.png")
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# Windows Icon support for PIL
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 96-05-27 fl Created
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1996.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
|
|
||||||
# This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis
|
|
||||||
# <casadebender@gmail.com>.
|
|
||||||
# https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
|
|
||||||
#
|
|
||||||
# Copyright 2008 Bryan Davis
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
||||||
# not use this file except in compliance with the License. You may obtain
|
|
||||||
# a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
# Icon format references:
|
|
||||||
# * https://en.wikipedia.org/wiki/ICO_(file_format)
|
|
||||||
# * https://msdn.microsoft.com/en-us/library/ms997538.aspx
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import warnings
|
|
||||||
from io import BytesIO
|
|
||||||
from math import ceil, log
|
|
||||||
from typing import IO, NamedTuple
|
|
||||||
|
|
||||||
from . import BmpImagePlugin, Image, ImageFile, PngImagePlugin
|
|
||||||
from ._binary import i16le as i16
|
|
||||||
from ._binary import i32le as i32
|
|
||||||
from ._binary import o8
|
|
||||||
from ._binary import o16le as o16
|
|
||||||
from ._binary import o32le as o32
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
_MAGIC = b"\0\0\1\0"
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
fp.write(_MAGIC) # (2+2)
|
|
||||||
bmp = im.encoderinfo.get("bitmap_format") == "bmp"
|
|
||||||
sizes = im.encoderinfo.get(
|
|
||||||
"sizes",
|
|
||||||
[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)],
|
|
||||||
)
|
|
||||||
frames = []
|
|
||||||
provided_ims = [im] + im.encoderinfo.get("append_images", [])
|
|
||||||
width, height = im.size
|
|
||||||
for size in sorted(set(sizes)):
|
|
||||||
if size[0] > width or size[1] > height or size[0] > 256 or size[1] > 256:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for provided_im in provided_ims:
|
|
||||||
if provided_im.size != size:
|
|
||||||
continue
|
|
||||||
frames.append(provided_im)
|
|
||||||
if bmp:
|
|
||||||
bits = BmpImagePlugin.SAVE[provided_im.mode][1]
|
|
||||||
bits_used = [bits]
|
|
||||||
for other_im in provided_ims:
|
|
||||||
if other_im.size != size:
|
|
||||||
continue
|
|
||||||
bits = BmpImagePlugin.SAVE[other_im.mode][1]
|
|
||||||
if bits not in bits_used:
|
|
||||||
# Another image has been supplied for this size
|
|
||||||
# with a different bit depth
|
|
||||||
frames.append(other_im)
|
|
||||||
bits_used.append(bits)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# TODO: invent a more convenient method for proportional scalings
|
|
||||||
frame = provided_im.copy()
|
|
||||||
frame.thumbnail(size, Image.Resampling.LANCZOS, reducing_gap=None)
|
|
||||||
frames.append(frame)
|
|
||||||
fp.write(o16(len(frames))) # idCount(2)
|
|
||||||
offset = fp.tell() + len(frames) * 16
|
|
||||||
for frame in frames:
|
|
||||||
width, height = frame.size
|
|
||||||
# 0 means 256
|
|
||||||
fp.write(o8(width if width < 256 else 0)) # bWidth(1)
|
|
||||||
fp.write(o8(height if height < 256 else 0)) # bHeight(1)
|
|
||||||
|
|
||||||
bits, colors = BmpImagePlugin.SAVE[frame.mode][1:] if bmp else (32, 0)
|
|
||||||
fp.write(o8(colors)) # bColorCount(1)
|
|
||||||
fp.write(b"\0") # bReserved(1)
|
|
||||||
fp.write(b"\0\0") # wPlanes(2)
|
|
||||||
fp.write(o16(bits)) # wBitCount(2)
|
|
||||||
|
|
||||||
image_io = BytesIO()
|
|
||||||
if bmp:
|
|
||||||
frame.save(image_io, "dib")
|
|
||||||
|
|
||||||
if bits != 32:
|
|
||||||
and_mask = Image.new("1", size)
|
|
||||||
ImageFile._save(
|
|
||||||
and_mask,
|
|
||||||
image_io,
|
|
||||||
[ImageFile._Tile("raw", (0, 0) + size, 0, ("1", 0, -1))],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
frame.save(image_io, "png")
|
|
||||||
image_io.seek(0)
|
|
||||||
image_bytes = image_io.read()
|
|
||||||
if bmp:
|
|
||||||
image_bytes = image_bytes[:8] + o32(height * 2) + image_bytes[12:]
|
|
||||||
bytes_len = len(image_bytes)
|
|
||||||
fp.write(o32(bytes_len)) # dwBytesInRes(4)
|
|
||||||
fp.write(o32(offset)) # dwImageOffset(4)
|
|
||||||
current = fp.tell()
|
|
||||||
fp.seek(offset)
|
|
||||||
fp.write(image_bytes)
|
|
||||||
offset = offset + bytes_len
|
|
||||||
fp.seek(current)
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(_MAGIC)
|
|
||||||
|
|
||||||
|
|
||||||
class IconHeader(NamedTuple):
|
|
||||||
width: int
|
|
||||||
height: int
|
|
||||||
nb_color: int
|
|
||||||
reserved: int
|
|
||||||
planes: int
|
|
||||||
bpp: int
|
|
||||||
size: int
|
|
||||||
offset: int
|
|
||||||
dim: tuple[int, int]
|
|
||||||
square: int
|
|
||||||
color_depth: int
|
|
||||||
|
|
||||||
|
|
||||||
class IcoFile:
|
|
||||||
def __init__(self, buf: IO[bytes]) -> None:
|
|
||||||
"""
|
|
||||||
Parse image from file-like object containing ico file data
|
|
||||||
"""
|
|
||||||
|
|
||||||
# check magic
|
|
||||||
s = buf.read(6)
|
|
||||||
if not _accept(s):
|
|
||||||
msg = "not an ICO file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self.buf = buf
|
|
||||||
self.entry = []
|
|
||||||
|
|
||||||
# Number of items in file
|
|
||||||
self.nb_items = i16(s, 4)
|
|
||||||
|
|
||||||
# Get headers for each item
|
|
||||||
for i in range(self.nb_items):
|
|
||||||
s = buf.read(16)
|
|
||||||
|
|
||||||
# See Wikipedia
|
|
||||||
width = s[0] or 256
|
|
||||||
height = s[1] or 256
|
|
||||||
|
|
||||||
# No. of colors in image (0 if >=8bpp)
|
|
||||||
nb_color = s[2]
|
|
||||||
bpp = i16(s, 6)
|
|
||||||
icon_header = IconHeader(
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
nb_color=nb_color,
|
|
||||||
reserved=s[3],
|
|
||||||
planes=i16(s, 4),
|
|
||||||
bpp=i16(s, 6),
|
|
||||||
size=i32(s, 8),
|
|
||||||
offset=i32(s, 12),
|
|
||||||
dim=(width, height),
|
|
||||||
square=width * height,
|
|
||||||
# See Wikipedia notes about color depth.
|
|
||||||
# We need this just to differ images with equal sizes
|
|
||||||
color_depth=bpp or (nb_color != 0 and ceil(log(nb_color, 2))) or 256,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.entry.append(icon_header)
|
|
||||||
|
|
||||||
self.entry = sorted(self.entry, key=lambda x: x.color_depth)
|
|
||||||
# ICO images are usually squares
|
|
||||||
self.entry = sorted(self.entry, key=lambda x: x.square, reverse=True)
|
|
||||||
|
|
||||||
def sizes(self) -> set[tuple[int, int]]:
|
|
||||||
"""
|
|
||||||
Get a set of all available icon sizes and color depths.
|
|
||||||
"""
|
|
||||||
return {(h.width, h.height) for h in self.entry}
|
|
||||||
|
|
||||||
def getentryindex(self, size: tuple[int, int], bpp: int | bool = False) -> int:
|
|
||||||
for i, h in enumerate(self.entry):
|
|
||||||
if size == h.dim and (bpp is False or bpp == h.color_depth):
|
|
||||||
return i
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def getimage(self, size: tuple[int, int], bpp: int | bool = False) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Get an image from the icon
|
|
||||||
"""
|
|
||||||
return self.frame(self.getentryindex(size, bpp))
|
|
||||||
|
|
||||||
def frame(self, idx: int) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Get an image from frame idx
|
|
||||||
"""
|
|
||||||
|
|
||||||
header = self.entry[idx]
|
|
||||||
|
|
||||||
self.buf.seek(header.offset)
|
|
||||||
data = self.buf.read(8)
|
|
||||||
self.buf.seek(header.offset)
|
|
||||||
|
|
||||||
im: Image.Image
|
|
||||||
if data[:8] == PngImagePlugin._MAGIC:
|
|
||||||
# png frame
|
|
||||||
im = PngImagePlugin.PngImageFile(self.buf)
|
|
||||||
Image._decompression_bomb_check(im.size)
|
|
||||||
else:
|
|
||||||
# XOR + AND mask bmp frame
|
|
||||||
im = BmpImagePlugin.DibImageFile(self.buf)
|
|
||||||
Image._decompression_bomb_check(im.size)
|
|
||||||
|
|
||||||
# change tile dimension to only encompass XOR image
|
|
||||||
im._size = (im.size[0], int(im.size[1] / 2))
|
|
||||||
d, e, o, a = im.tile[0]
|
|
||||||
im.tile[0] = ImageFile._Tile(d, (0, 0) + im.size, o, a)
|
|
||||||
|
|
||||||
# figure out where AND mask image starts
|
|
||||||
if header.bpp == 32:
|
|
||||||
# 32-bit color depth icon image allows semitransparent areas
|
|
||||||
# PIL's DIB format ignores transparency bits, recover them.
|
|
||||||
# The DIB is packed in BGRX byte order where X is the alpha
|
|
||||||
# channel.
|
|
||||||
|
|
||||||
# Back up to start of bmp data
|
|
||||||
self.buf.seek(o)
|
|
||||||
# extract every 4th byte (eg. 3,7,11,15,...)
|
|
||||||
alpha_bytes = self.buf.read(im.size[0] * im.size[1] * 4)[3::4]
|
|
||||||
|
|
||||||
# convert to an 8bpp grayscale image
|
|
||||||
try:
|
|
||||||
mask = Image.frombuffer(
|
|
||||||
"L", # 8bpp
|
|
||||||
im.size, # (w, h)
|
|
||||||
alpha_bytes, # source chars
|
|
||||||
"raw", # raw decoder
|
|
||||||
("L", 0, -1), # 8bpp inverted, unpadded, reversed
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
if ImageFile.LOAD_TRUNCATED_IMAGES:
|
|
||||||
mask = None
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
# get AND image from end of bitmap
|
|
||||||
w = im.size[0]
|
|
||||||
if (w % 32) > 0:
|
|
||||||
# bitmap row data is aligned to word boundaries
|
|
||||||
w += 32 - (im.size[0] % 32)
|
|
||||||
|
|
||||||
# the total mask data is
|
|
||||||
# padded row size * height / bits per char
|
|
||||||
|
|
||||||
total_bytes = int((w * im.size[1]) / 8)
|
|
||||||
and_mask_offset = header.offset + header.size - total_bytes
|
|
||||||
|
|
||||||
self.buf.seek(and_mask_offset)
|
|
||||||
mask_data = self.buf.read(total_bytes)
|
|
||||||
|
|
||||||
# convert raw data to image
|
|
||||||
try:
|
|
||||||
mask = Image.frombuffer(
|
|
||||||
"1", # 1 bpp
|
|
||||||
im.size, # (w, h)
|
|
||||||
mask_data, # source chars
|
|
||||||
"raw", # raw decoder
|
|
||||||
("1;I", int(w / 8), -1), # 1bpp inverted, padded, reversed
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
if ImageFile.LOAD_TRUNCATED_IMAGES:
|
|
||||||
mask = None
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
# now we have two images, im is XOR image and mask is AND image
|
|
||||||
|
|
||||||
# apply mask image as alpha channel
|
|
||||||
if mask:
|
|
||||||
im = im.convert("RGBA")
|
|
||||||
im.putalpha(mask)
|
|
||||||
|
|
||||||
return im
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for Windows Icon files.
|
|
||||||
|
|
||||||
|
|
||||||
class IcoImageFile(ImageFile.ImageFile):
|
|
||||||
"""
|
|
||||||
PIL read-only image support for Microsoft Windows .ico files.
|
|
||||||
|
|
||||||
By default the largest resolution image in the file will be loaded. This
|
|
||||||
can be changed by altering the 'size' attribute before calling 'load'.
|
|
||||||
|
|
||||||
The info dictionary has a key 'sizes' that is a list of the sizes available
|
|
||||||
in the icon file.
|
|
||||||
|
|
||||||
Handles classic, XP and Vista icon formats.
|
|
||||||
|
|
||||||
When saving, PNG compression is used. Support for this was only added in
|
|
||||||
Windows Vista. If you are unable to view the icon in Windows, convert the
|
|
||||||
image to "RGBA" mode before saving.
|
|
||||||
|
|
||||||
This plugin is a refactored version of Win32IconImagePlugin by Bryan Davis
|
|
||||||
<casadebender@gmail.com>.
|
|
||||||
https://code.google.com/archive/p/casadebender/wikis/Win32IconImagePlugin.wiki
|
|
||||||
"""
|
|
||||||
|
|
||||||
format = "ICO"
|
|
||||||
format_description = "Windows Icon"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
self.ico = IcoFile(self.fp)
|
|
||||||
self.info["sizes"] = self.ico.sizes()
|
|
||||||
self.size = self.ico.entry[0].dim
|
|
||||||
self.load()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def size(self) -> tuple[int, int]:
|
|
||||||
return self._size
|
|
||||||
|
|
||||||
@size.setter
|
|
||||||
def size(self, value: tuple[int, int]) -> None:
|
|
||||||
if value not in self.info["sizes"]:
|
|
||||||
msg = "This is not one of the allowed sizes of this image"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self._size = value
|
|
||||||
|
|
||||||
def load(self) -> Image.core.PixelAccess | None:
|
|
||||||
if self._im is not None and self.im.size == self.size:
|
|
||||||
# Already loaded
|
|
||||||
return Image.Image.load(self)
|
|
||||||
im = self.ico.getimage(self.size)
|
|
||||||
# if tile is PNG, it won't really be loaded yet
|
|
||||||
im.load()
|
|
||||||
self.im = im.im
|
|
||||||
self._mode = im.mode
|
|
||||||
if im.palette:
|
|
||||||
self.palette = im.palette
|
|
||||||
if im.size != self.size:
|
|
||||||
warnings.warn("Image was not the expected size")
|
|
||||||
|
|
||||||
index = self.ico.getentryindex(self.size)
|
|
||||||
sizes = list(self.info["sizes"])
|
|
||||||
sizes[index] = im.size
|
|
||||||
self.info["sizes"] = set(sizes)
|
|
||||||
|
|
||||||
self.size = im.size
|
|
||||||
return Image.Image.load(self)
|
|
||||||
|
|
||||||
def load_seek(self, pos: int) -> None:
|
|
||||||
# Flag the ImageFile.Parser so that it
|
|
||||||
# just does all the decode at the end.
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(IcoImageFile.format, IcoImageFile, _accept)
|
|
||||||
Image.register_save(IcoImageFile.format, _save)
|
|
||||||
Image.register_extension(IcoImageFile.format, ".ico")
|
|
||||||
|
|
||||||
Image.register_mime(IcoImageFile.format, "image/x-icon")
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# IFUNC IM file handling for PIL
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 1995-09-01 fl Created.
|
|
||||||
# 1997-01-03 fl Save palette images
|
|
||||||
# 1997-01-08 fl Added sequence support
|
|
||||||
# 1997-01-23 fl Added P and RGB save support
|
|
||||||
# 1997-05-31 fl Read floating point images
|
|
||||||
# 1997-06-22 fl Save floating point images
|
|
||||||
# 1997-08-27 fl Read and save 1-bit images
|
|
||||||
# 1998-06-25 fl Added support for RGB+LUT images
|
|
||||||
# 1998-07-02 fl Added support for YCC images
|
|
||||||
# 1998-07-15 fl Renamed offset attribute to avoid name clash
|
|
||||||
# 1998-12-29 fl Added I;16 support
|
|
||||||
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.7)
|
|
||||||
# 2003-09-26 fl Added LA/PA support
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
|
||||||
# Copyright (c) 1995-2001 by Fredrik Lundh.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import IO, Any
|
|
||||||
|
|
||||||
from . import Image, ImageFile, ImagePalette
|
|
||||||
from ._util import DeferredError
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Standard tags
|
|
||||||
|
|
||||||
COMMENT = "Comment"
|
|
||||||
DATE = "Date"
|
|
||||||
EQUIPMENT = "Digitalization equipment"
|
|
||||||
FRAMES = "File size (no of images)"
|
|
||||||
LUT = "Lut"
|
|
||||||
NAME = "Name"
|
|
||||||
SCALE = "Scale (x,y)"
|
|
||||||
SIZE = "Image size (x*y)"
|
|
||||||
MODE = "Image type"
|
|
||||||
|
|
||||||
TAGS = {
|
|
||||||
COMMENT: 0,
|
|
||||||
DATE: 0,
|
|
||||||
EQUIPMENT: 0,
|
|
||||||
FRAMES: 0,
|
|
||||||
LUT: 0,
|
|
||||||
NAME: 0,
|
|
||||||
SCALE: 0,
|
|
||||||
SIZE: 0,
|
|
||||||
MODE: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
OPEN = {
|
|
||||||
# ifunc93/p3cfunc formats
|
|
||||||
"0 1 image": ("1", "1"),
|
|
||||||
"L 1 image": ("1", "1"),
|
|
||||||
"Greyscale image": ("L", "L"),
|
|
||||||
"Grayscale image": ("L", "L"),
|
|
||||||
"RGB image": ("RGB", "RGB;L"),
|
|
||||||
"RLB image": ("RGB", "RLB"),
|
|
||||||
"RYB image": ("RGB", "RLB"),
|
|
||||||
"B1 image": ("1", "1"),
|
|
||||||
"B2 image": ("P", "P;2"),
|
|
||||||
"B4 image": ("P", "P;4"),
|
|
||||||
"X 24 image": ("RGB", "RGB"),
|
|
||||||
"L 32 S image": ("I", "I;32"),
|
|
||||||
"L 32 F image": ("F", "F;32"),
|
|
||||||
# old p3cfunc formats
|
|
||||||
"RGB3 image": ("RGB", "RGB;T"),
|
|
||||||
"RYB3 image": ("RGB", "RYB;T"),
|
|
||||||
# extensions
|
|
||||||
"LA image": ("LA", "LA;L"),
|
|
||||||
"PA image": ("LA", "PA;L"),
|
|
||||||
"RGBA image": ("RGBA", "RGBA;L"),
|
|
||||||
"RGBX image": ("RGB", "RGBX;L"),
|
|
||||||
"CMYK image": ("CMYK", "CMYK;L"),
|
|
||||||
"YCC image": ("YCbCr", "YCbCr;L"),
|
|
||||||
}
|
|
||||||
|
|
||||||
# ifunc95 extensions
|
|
||||||
for i in ["8", "8S", "16", "16S", "32", "32F"]:
|
|
||||||
OPEN[f"L {i} image"] = ("F", f"F;{i}")
|
|
||||||
OPEN[f"L*{i} image"] = ("F", f"F;{i}")
|
|
||||||
for i in ["16", "16L", "16B"]:
|
|
||||||
OPEN[f"L {i} image"] = (f"I;{i}", f"I;{i}")
|
|
||||||
OPEN[f"L*{i} image"] = (f"I;{i}", f"I;{i}")
|
|
||||||
for i in ["32S"]:
|
|
||||||
OPEN[f"L {i} image"] = ("I", f"I;{i}")
|
|
||||||
OPEN[f"L*{i} image"] = ("I", f"I;{i}")
|
|
||||||
for j in range(2, 33):
|
|
||||||
OPEN[f"L*{j} image"] = ("F", f"F;{j}")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Read IM directory
|
|
||||||
|
|
||||||
split = re.compile(rb"^([A-Za-z][^:]*):[ \t]*(.*)[ \t]*$")
|
|
||||||
|
|
||||||
|
|
||||||
def number(s: Any) -> float:
|
|
||||||
try:
|
|
||||||
return int(s)
|
|
||||||
except ValueError:
|
|
||||||
return float(s)
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for the IFUNC IM file format.
|
|
||||||
|
|
||||||
|
|
||||||
class ImImageFile(ImageFile.ImageFile):
|
|
||||||
format = "IM"
|
|
||||||
format_description = "IFUNC Image Memory"
|
|
||||||
_close_exclusive_fp_after_loading = False
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
# Quick rejection: if there's not an LF among the first
|
|
||||||
# 100 bytes, this is (probably) not a text header.
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
if b"\n" not in self.fp.read(100):
|
|
||||||
msg = "not an IM file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
self.fp.seek(0)
|
|
||||||
|
|
||||||
n = 0
|
|
||||||
|
|
||||||
# Default values
|
|
||||||
self.info[MODE] = "L"
|
|
||||||
self.info[SIZE] = (512, 512)
|
|
||||||
self.info[FRAMES] = 1
|
|
||||||
|
|
||||||
self.rawmode = "L"
|
|
||||||
|
|
||||||
while True:
|
|
||||||
s = self.fp.read(1)
|
|
||||||
|
|
||||||
# Some versions of IFUNC uses \n\r instead of \r\n...
|
|
||||||
if s == b"\r":
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not s or s == b"\0" or s == b"\x1a":
|
|
||||||
break
|
|
||||||
|
|
||||||
# FIXME: this may read whole file if not a text file
|
|
||||||
s = s + self.fp.readline()
|
|
||||||
|
|
||||||
if len(s) > 100:
|
|
||||||
msg = "not an IM file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
if s.endswith(b"\r\n"):
|
|
||||||
s = s[:-2]
|
|
||||||
elif s.endswith(b"\n"):
|
|
||||||
s = s[:-1]
|
|
||||||
|
|
||||||
try:
|
|
||||||
m = split.match(s)
|
|
||||||
except re.error as e:
|
|
||||||
msg = "not an IM file"
|
|
||||||
raise SyntaxError(msg) from e
|
|
||||||
|
|
||||||
if m:
|
|
||||||
k, v = m.group(1, 2)
|
|
||||||
|
|
||||||
# Don't know if this is the correct encoding,
|
|
||||||
# but a decent guess (I guess)
|
|
||||||
k = k.decode("latin-1", "replace")
|
|
||||||
v = v.decode("latin-1", "replace")
|
|
||||||
|
|
||||||
# Convert value as appropriate
|
|
||||||
if k in [FRAMES, SCALE, SIZE]:
|
|
||||||
v = v.replace("*", ",")
|
|
||||||
v = tuple(map(number, v.split(",")))
|
|
||||||
if len(v) == 1:
|
|
||||||
v = v[0]
|
|
||||||
elif k == MODE and v in OPEN:
|
|
||||||
v, self.rawmode = OPEN[v]
|
|
||||||
|
|
||||||
# Add to dictionary. Note that COMMENT tags are
|
|
||||||
# combined into a list of strings.
|
|
||||||
if k == COMMENT:
|
|
||||||
if k in self.info:
|
|
||||||
self.info[k].append(v)
|
|
||||||
else:
|
|
||||||
self.info[k] = [v]
|
|
||||||
else:
|
|
||||||
self.info[k] = v
|
|
||||||
|
|
||||||
if k in TAGS:
|
|
||||||
n += 1
|
|
||||||
|
|
||||||
else:
|
|
||||||
msg = f"Syntax error in IM header: {s.decode('ascii', 'replace')}"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
if not n:
|
|
||||||
msg = "Not an IM file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
# Basic attributes
|
|
||||||
self._size = self.info[SIZE]
|
|
||||||
self._mode = self.info[MODE]
|
|
||||||
|
|
||||||
# Skip forward to start of image data
|
|
||||||
while s and not s.startswith(b"\x1a"):
|
|
||||||
s = self.fp.read(1)
|
|
||||||
if not s:
|
|
||||||
msg = "File truncated"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
if LUT in self.info:
|
|
||||||
# convert lookup table to palette or lut attribute
|
|
||||||
palette = self.fp.read(768)
|
|
||||||
greyscale = 1 # greyscale palette
|
|
||||||
linear = 1 # linear greyscale palette
|
|
||||||
for i in range(256):
|
|
||||||
if palette[i] == palette[i + 256] == palette[i + 512]:
|
|
||||||
if palette[i] != i:
|
|
||||||
linear = 0
|
|
||||||
else:
|
|
||||||
greyscale = 0
|
|
||||||
if self.mode in ["L", "LA", "P", "PA"]:
|
|
||||||
if greyscale:
|
|
||||||
if not linear:
|
|
||||||
self.lut = list(palette[:256])
|
|
||||||
else:
|
|
||||||
if self.mode in ["L", "P"]:
|
|
||||||
self._mode = self.rawmode = "P"
|
|
||||||
elif self.mode in ["LA", "PA"]:
|
|
||||||
self._mode = "PA"
|
|
||||||
self.rawmode = "PA;L"
|
|
||||||
self.palette = ImagePalette.raw("RGB;L", palette)
|
|
||||||
elif self.mode == "RGB":
|
|
||||||
if not greyscale or not linear:
|
|
||||||
self.lut = list(palette)
|
|
||||||
|
|
||||||
self.frame = 0
|
|
||||||
|
|
||||||
self.__offset = offs = self.fp.tell()
|
|
||||||
|
|
||||||
self._fp = self.fp # FIXME: hack
|
|
||||||
|
|
||||||
if self.rawmode.startswith("F;"):
|
|
||||||
# ifunc95 formats
|
|
||||||
try:
|
|
||||||
# use bit decoder (if necessary)
|
|
||||||
bits = int(self.rawmode[2:])
|
|
||||||
if bits not in [8, 16, 32]:
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile(
|
|
||||||
"bit", (0, 0) + self.size, offs, (bits, 8, 3, 0, -1)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
return
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self.rawmode in ["RGB;T", "RYB;T"]:
|
|
||||||
# Old LabEye/3PC files. Would be very surprised if anyone
|
|
||||||
# ever stumbled upon such a file ;-)
|
|
||||||
size = self.size[0] * self.size[1]
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile("raw", (0, 0) + self.size, offs, ("G", 0, -1)),
|
|
||||||
ImageFile._Tile("raw", (0, 0) + self.size, offs + size, ("R", 0, -1)),
|
|
||||||
ImageFile._Tile(
|
|
||||||
"raw", (0, 0) + self.size, offs + 2 * size, ("B", 0, -1)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
# LabEye/IFUNC files
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))
|
|
||||||
]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def n_frames(self) -> int:
|
|
||||||
return self.info[FRAMES]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_animated(self) -> bool:
|
|
||||||
return self.info[FRAMES] > 1
|
|
||||||
|
|
||||||
def seek(self, frame: int) -> None:
|
|
||||||
if not self._seek_check(frame):
|
|
||||||
return
|
|
||||||
if isinstance(self._fp, DeferredError):
|
|
||||||
raise self._fp.ex
|
|
||||||
|
|
||||||
self.frame = frame
|
|
||||||
|
|
||||||
if self.mode == "1":
|
|
||||||
bits = 1
|
|
||||||
else:
|
|
||||||
bits = 8 * len(self.mode)
|
|
||||||
|
|
||||||
size = ((self.size[0] * bits + 7) // 8) * self.size[1]
|
|
||||||
offs = self.__offset + frame * size
|
|
||||||
|
|
||||||
self.fp = self._fp
|
|
||||||
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile("raw", (0, 0) + self.size, offs, (self.rawmode, 0, -1))
|
|
||||||
]
|
|
||||||
|
|
||||||
def tell(self) -> int:
|
|
||||||
return self.frame
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Save IM files
|
|
||||||
|
|
||||||
|
|
||||||
SAVE = {
|
|
||||||
# mode: (im type, raw mode)
|
|
||||||
"1": ("0 1", "1"),
|
|
||||||
"L": ("Greyscale", "L"),
|
|
||||||
"LA": ("LA", "LA;L"),
|
|
||||||
"P": ("Greyscale", "P"),
|
|
||||||
"PA": ("LA", "PA;L"),
|
|
||||||
"I": ("L 32S", "I;32S"),
|
|
||||||
"I;16": ("L 16", "I;16"),
|
|
||||||
"I;16L": ("L 16L", "I;16L"),
|
|
||||||
"I;16B": ("L 16B", "I;16B"),
|
|
||||||
"F": ("L 32F", "F;32F"),
|
|
||||||
"RGB": ("RGB", "RGB;L"),
|
|
||||||
"RGBA": ("RGBA", "RGBA;L"),
|
|
||||||
"RGBX": ("RGBX", "RGBX;L"),
|
|
||||||
"CMYK": ("CMYK", "CMYK;L"),
|
|
||||||
"YCbCr": ("YCC", "YCbCr;L"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
try:
|
|
||||||
image_type, rawmode = SAVE[im.mode]
|
|
||||||
except KeyError as e:
|
|
||||||
msg = f"Cannot save {im.mode} images as IM"
|
|
||||||
raise ValueError(msg) from e
|
|
||||||
|
|
||||||
frames = im.encoderinfo.get("frames", 1)
|
|
||||||
|
|
||||||
fp.write(f"Image type: {image_type} image\r\n".encode("ascii"))
|
|
||||||
if filename:
|
|
||||||
# Each line must be 100 characters or less,
|
|
||||||
# or: SyntaxError("not an IM file")
|
|
||||||
# 8 characters are used for "Name: " and "\r\n"
|
|
||||||
# Keep just the filename, ditch the potentially overlong path
|
|
||||||
if isinstance(filename, bytes):
|
|
||||||
filename = filename.decode("ascii")
|
|
||||||
name, ext = os.path.splitext(os.path.basename(filename))
|
|
||||||
name = "".join([name[: 92 - len(ext)], ext])
|
|
||||||
|
|
||||||
fp.write(f"Name: {name}\r\n".encode("ascii"))
|
|
||||||
fp.write(f"Image size (x*y): {im.size[0]}*{im.size[1]}\r\n".encode("ascii"))
|
|
||||||
fp.write(f"File size (no of images): {frames}\r\n".encode("ascii"))
|
|
||||||
if im.mode in ["P", "PA"]:
|
|
||||||
fp.write(b"Lut: 1\r\n")
|
|
||||||
fp.write(b"\000" * (511 - fp.tell()) + b"\032")
|
|
||||||
if im.mode in ["P", "PA"]:
|
|
||||||
im_palette = im.im.getpalette("RGB", "RGB;L")
|
|
||||||
colors = len(im_palette) // 3
|
|
||||||
palette = b""
|
|
||||||
for i in range(3):
|
|
||||||
palette += im_palette[colors * i : colors * (i + 1)]
|
|
||||||
palette += b"\x00" * (256 - colors)
|
|
||||||
fp.write(palette) # 768 bytes
|
|
||||||
ImageFile._save(
|
|
||||||
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, 0, -1))]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Registry
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(ImImageFile.format, ImImageFile)
|
|
||||||
Image.register_save(ImImageFile.format, _save)
|
|
||||||
|
|
||||||
Image.register_extension(ImImageFile.format, ".im")
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,311 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# standard channel operations
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1996-03-24 fl Created
|
|
||||||
# 1996-08-13 fl Added logical operations (for "1" images)
|
|
||||||
# 2000-10-12 fl Added offset method (from Image.py)
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2000 by Secret Labs AB
|
|
||||||
# Copyright (c) 1996-2000 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
|
|
||||||
def constant(image: Image.Image, value: int) -> Image.Image:
|
|
||||||
"""Fill a channel with a given gray level.
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
return Image.new("L", image.size, value)
|
|
||||||
|
|
||||||
|
|
||||||
def duplicate(image: Image.Image) -> Image.Image:
|
|
||||||
"""Copy a channel. Alias for :py:meth:`PIL.Image.Image.copy`.
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
return image.copy()
|
|
||||||
|
|
||||||
|
|
||||||
def invert(image: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Invert an image (channel). ::
|
|
||||||
|
|
||||||
out = MAX - image
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image.load()
|
|
||||||
return image._new(image.im.chop_invert())
|
|
||||||
|
|
||||||
|
|
||||||
def lighter(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Compares the two images, pixel by pixel, and returns a new image containing
|
|
||||||
the lighter values. ::
|
|
||||||
|
|
||||||
out = max(image1, image2)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_lighter(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def darker(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Compares the two images, pixel by pixel, and returns a new image containing
|
|
||||||
the darker values. ::
|
|
||||||
|
|
||||||
out = min(image1, image2)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_darker(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def difference(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Returns the absolute value of the pixel-by-pixel difference between the two
|
|
||||||
images. ::
|
|
||||||
|
|
||||||
out = abs(image1 - image2)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_difference(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def multiply(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Superimposes two images on top of each other.
|
|
||||||
|
|
||||||
If you multiply an image with a solid black image, the result is black. If
|
|
||||||
you multiply with a solid white image, the image is unaffected. ::
|
|
||||||
|
|
||||||
out = image1 * image2 / MAX
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_multiply(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def screen(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Superimposes two inverted images on top of each other. ::
|
|
||||||
|
|
||||||
out = MAX - ((MAX - image1) * (MAX - image2) / MAX)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_screen(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def soft_light(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Superimposes two images on top of each other using the Soft Light algorithm
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_soft_light(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def hard_light(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Superimposes two images on top of each other using the Hard Light algorithm
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_hard_light(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def overlay(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Superimposes two images on top of each other using the Overlay algorithm
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_overlay(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def add(
|
|
||||||
image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Adds two images, dividing the result by scale and adding the
|
|
||||||
offset. If omitted, scale defaults to 1.0, and offset to 0.0. ::
|
|
||||||
|
|
||||||
out = ((image1 + image2) / scale + offset)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_add(image2.im, scale, offset))
|
|
||||||
|
|
||||||
|
|
||||||
def subtract(
|
|
||||||
image1: Image.Image, image2: Image.Image, scale: float = 1.0, offset: float = 0
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Subtracts two images, dividing the result by scale and adding the offset.
|
|
||||||
If omitted, scale defaults to 1.0, and offset to 0.0. ::
|
|
||||||
|
|
||||||
out = ((image1 - image2) / scale + offset)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_subtract(image2.im, scale, offset))
|
|
||||||
|
|
||||||
|
|
||||||
def add_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""Add two images, without clipping the result. ::
|
|
||||||
|
|
||||||
out = ((image1 + image2) % MAX)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_add_modulo(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def subtract_modulo(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""Subtract two images, without clipping the result. ::
|
|
||||||
|
|
||||||
out = ((image1 - image2) % MAX)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_subtract_modulo(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def logical_and(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""Logical AND between two images.
|
|
||||||
|
|
||||||
Both of the images must have mode "1". If you would like to perform a
|
|
||||||
logical AND on an image with a mode other than "1", try
|
|
||||||
:py:meth:`~PIL.ImageChops.multiply` instead, using a black-and-white mask
|
|
||||||
as the second image. ::
|
|
||||||
|
|
||||||
out = ((image1 and image2) % MAX)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_and(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def logical_or(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""Logical OR between two images.
|
|
||||||
|
|
||||||
Both of the images must have mode "1". ::
|
|
||||||
|
|
||||||
out = ((image1 or image2) % MAX)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_or(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def logical_xor(image1: Image.Image, image2: Image.Image) -> Image.Image:
|
|
||||||
"""Logical XOR between two images.
|
|
||||||
|
|
||||||
Both of the images must have mode "1". ::
|
|
||||||
|
|
||||||
out = ((bool(image1) != bool(image2)) % MAX)
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
image1.load()
|
|
||||||
image2.load()
|
|
||||||
return image1._new(image1.im.chop_xor(image2.im))
|
|
||||||
|
|
||||||
|
|
||||||
def blend(image1: Image.Image, image2: Image.Image, alpha: float) -> Image.Image:
|
|
||||||
"""Blend images using constant transparency weight. Alias for
|
|
||||||
:py:func:`PIL.Image.blend`.
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
return Image.blend(image1, image2, alpha)
|
|
||||||
|
|
||||||
|
|
||||||
def composite(
|
|
||||||
image1: Image.Image, image2: Image.Image, mask: Image.Image
|
|
||||||
) -> Image.Image:
|
|
||||||
"""Create composite using transparency mask. Alias for
|
|
||||||
:py:func:`PIL.Image.composite`.
|
|
||||||
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
return Image.composite(image1, image2, mask)
|
|
||||||
|
|
||||||
|
|
||||||
def offset(image: Image.Image, xoffset: int, yoffset: int | None = None) -> Image.Image:
|
|
||||||
"""Returns a copy of the image where data has been offset by the given
|
|
||||||
distances. Data wraps around the edges. If ``yoffset`` is omitted, it
|
|
||||||
is assumed to be equal to ``xoffset``.
|
|
||||||
|
|
||||||
:param image: Input image.
|
|
||||||
:param xoffset: The horizontal distance.
|
|
||||||
:param yoffset: The vertical distance. If omitted, both
|
|
||||||
distances are set to the same value.
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
|
|
||||||
if yoffset is None:
|
|
||||||
yoffset = xoffset
|
|
||||||
image.load()
|
|
||||||
return image._new(image.im.offset(xoffset, yoffset))
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,320 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# map CSS3-style colour description strings to RGB
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2002-10-24 fl Added support for CSS-style color strings
|
|
||||||
# 2002-12-15 fl Added RGBA support
|
|
||||||
# 2004-03-27 fl Fixed remaining int() problems for Python 1.5.2
|
|
||||||
# 2004-07-19 fl Fixed gray/grey spelling issues
|
|
||||||
# 2009-03-05 fl Fixed rounding error in grayscale calculation
|
|
||||||
#
|
|
||||||
# Copyright (c) 2002-2004 by Secret Labs AB
|
|
||||||
# Copyright (c) 2002-2004 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from functools import lru_cache
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def getrgb(color: str) -> tuple[int, int, int] | tuple[int, int, int, int]:
|
|
||||||
"""
|
|
||||||
Convert a color string to an RGB or RGBA tuple. If the string cannot be
|
|
||||||
parsed, this function raises a :py:exc:`ValueError` exception.
|
|
||||||
|
|
||||||
.. versionadded:: 1.1.4
|
|
||||||
|
|
||||||
:param color: A color string
|
|
||||||
:return: ``(red, green, blue[, alpha])``
|
|
||||||
"""
|
|
||||||
if len(color) > 100:
|
|
||||||
msg = "color specifier is too long"
|
|
||||||
raise ValueError(msg)
|
|
||||||
color = color.lower()
|
|
||||||
|
|
||||||
rgb = colormap.get(color, None)
|
|
||||||
if rgb:
|
|
||||||
if isinstance(rgb, tuple):
|
|
||||||
return rgb
|
|
||||||
rgb_tuple = getrgb(rgb)
|
|
||||||
assert len(rgb_tuple) == 3
|
|
||||||
colormap[color] = rgb_tuple
|
|
||||||
return rgb_tuple
|
|
||||||
|
|
||||||
# check for known string formats
|
|
||||||
if re.match("#[a-f0-9]{3}$", color):
|
|
||||||
return int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16)
|
|
||||||
|
|
||||||
if re.match("#[a-f0-9]{4}$", color):
|
|
||||||
return (
|
|
||||||
int(color[1] * 2, 16),
|
|
||||||
int(color[2] * 2, 16),
|
|
||||||
int(color[3] * 2, 16),
|
|
||||||
int(color[4] * 2, 16),
|
|
||||||
)
|
|
||||||
|
|
||||||
if re.match("#[a-f0-9]{6}$", color):
|
|
||||||
return int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16)
|
|
||||||
|
|
||||||
if re.match("#[a-f0-9]{8}$", color):
|
|
||||||
return (
|
|
||||||
int(color[1:3], 16),
|
|
||||||
int(color[3:5], 16),
|
|
||||||
int(color[5:7], 16),
|
|
||||||
int(color[7:9], 16),
|
|
||||||
)
|
|
||||||
|
|
||||||
m = re.match(r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color)
|
|
||||||
if m:
|
|
||||||
return int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
||||||
|
|
||||||
m = re.match(r"rgb\(\s*(\d+)%\s*,\s*(\d+)%\s*,\s*(\d+)%\s*\)$", color)
|
|
||||||
if m:
|
|
||||||
return (
|
|
||||||
int((int(m.group(1)) * 255) / 100.0 + 0.5),
|
|
||||||
int((int(m.group(2)) * 255) / 100.0 + 0.5),
|
|
||||||
int((int(m.group(3)) * 255) / 100.0 + 0.5),
|
|
||||||
)
|
|
||||||
|
|
||||||
m = re.match(
|
|
||||||
r"hsl\(\s*(\d+\.?\d*)\s*,\s*(\d+\.?\d*)%\s*,\s*(\d+\.?\d*)%\s*\)$", color
|
|
||||||
)
|
|
||||||
if m:
|
|
||||||
from colorsys import hls_to_rgb
|
|
||||||
|
|
||||||
rgb_floats = hls_to_rgb(
|
|
||||||
float(m.group(1)) / 360.0,
|
|
||||||
float(m.group(3)) / 100.0,
|
|
||||||
float(m.group(2)) / 100.0,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
int(rgb_floats[0] * 255 + 0.5),
|
|
||||||
int(rgb_floats[1] * 255 + 0.5),
|
|
||||||
int(rgb_floats[2] * 255 + 0.5),
|
|
||||||
)
|
|
||||||
|
|
||||||
m = re.match(
|
|
||||||
r"hs[bv]\(\s*(\d+\.?\d*)\s*,\s*(\d+\.?\d*)%\s*,\s*(\d+\.?\d*)%\s*\)$", color
|
|
||||||
)
|
|
||||||
if m:
|
|
||||||
from colorsys import hsv_to_rgb
|
|
||||||
|
|
||||||
rgb_floats = hsv_to_rgb(
|
|
||||||
float(m.group(1)) / 360.0,
|
|
||||||
float(m.group(2)) / 100.0,
|
|
||||||
float(m.group(3)) / 100.0,
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
int(rgb_floats[0] * 255 + 0.5),
|
|
||||||
int(rgb_floats[1] * 255 + 0.5),
|
|
||||||
int(rgb_floats[2] * 255 + 0.5),
|
|
||||||
)
|
|
||||||
|
|
||||||
m = re.match(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$", color)
|
|
||||||
if m:
|
|
||||||
return int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))
|
|
||||||
msg = f"unknown color specifier: {repr(color)}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def getcolor(color: str, mode: str) -> int | tuple[int, ...]:
|
|
||||||
"""
|
|
||||||
Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if
|
|
||||||
``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is
|
|
||||||
not color or a palette image, converts the RGB value to a grayscale value.
|
|
||||||
If the string cannot be parsed, this function raises a :py:exc:`ValueError`
|
|
||||||
exception.
|
|
||||||
|
|
||||||
.. versionadded:: 1.1.4
|
|
||||||
|
|
||||||
:param color: A color string
|
|
||||||
:param mode: Convert result to this mode
|
|
||||||
:return: ``graylevel, (graylevel, alpha) or (red, green, blue[, alpha])``
|
|
||||||
"""
|
|
||||||
# same as getrgb, but converts the result to the given mode
|
|
||||||
rgb, alpha = getrgb(color), 255
|
|
||||||
if len(rgb) == 4:
|
|
||||||
alpha = rgb[3]
|
|
||||||
rgb = rgb[:3]
|
|
||||||
|
|
||||||
if mode == "HSV":
|
|
||||||
from colorsys import rgb_to_hsv
|
|
||||||
|
|
||||||
r, g, b = rgb
|
|
||||||
h, s, v = rgb_to_hsv(r / 255, g / 255, b / 255)
|
|
||||||
return int(h * 255), int(s * 255), int(v * 255)
|
|
||||||
elif Image.getmodebase(mode) == "L":
|
|
||||||
r, g, b = rgb
|
|
||||||
# ITU-R Recommendation 601-2 for nonlinear RGB
|
|
||||||
# scaled to 24 bits to match the convert's implementation.
|
|
||||||
graylevel = (r * 19595 + g * 38470 + b * 7471 + 0x8000) >> 16
|
|
||||||
if mode[-1] == "A":
|
|
||||||
return graylevel, alpha
|
|
||||||
return graylevel
|
|
||||||
elif mode[-1] == "A":
|
|
||||||
return rgb + (alpha,)
|
|
||||||
return rgb
|
|
||||||
|
|
||||||
|
|
||||||
colormap: dict[str, str | tuple[int, int, int]] = {
|
|
||||||
# X11 colour table from https://drafts.csswg.org/css-color-4/, with
|
|
||||||
# gray/grey spelling issues fixed. This is a superset of HTML 4.0
|
|
||||||
# colour names used in CSS 1.
|
|
||||||
"aliceblue": "#f0f8ff",
|
|
||||||
"antiquewhite": "#faebd7",
|
|
||||||
"aqua": "#00ffff",
|
|
||||||
"aquamarine": "#7fffd4",
|
|
||||||
"azure": "#f0ffff",
|
|
||||||
"beige": "#f5f5dc",
|
|
||||||
"bisque": "#ffe4c4",
|
|
||||||
"black": "#000000",
|
|
||||||
"blanchedalmond": "#ffebcd",
|
|
||||||
"blue": "#0000ff",
|
|
||||||
"blueviolet": "#8a2be2",
|
|
||||||
"brown": "#a52a2a",
|
|
||||||
"burlywood": "#deb887",
|
|
||||||
"cadetblue": "#5f9ea0",
|
|
||||||
"chartreuse": "#7fff00",
|
|
||||||
"chocolate": "#d2691e",
|
|
||||||
"coral": "#ff7f50",
|
|
||||||
"cornflowerblue": "#6495ed",
|
|
||||||
"cornsilk": "#fff8dc",
|
|
||||||
"crimson": "#dc143c",
|
|
||||||
"cyan": "#00ffff",
|
|
||||||
"darkblue": "#00008b",
|
|
||||||
"darkcyan": "#008b8b",
|
|
||||||
"darkgoldenrod": "#b8860b",
|
|
||||||
"darkgray": "#a9a9a9",
|
|
||||||
"darkgrey": "#a9a9a9",
|
|
||||||
"darkgreen": "#006400",
|
|
||||||
"darkkhaki": "#bdb76b",
|
|
||||||
"darkmagenta": "#8b008b",
|
|
||||||
"darkolivegreen": "#556b2f",
|
|
||||||
"darkorange": "#ff8c00",
|
|
||||||
"darkorchid": "#9932cc",
|
|
||||||
"darkred": "#8b0000",
|
|
||||||
"darksalmon": "#e9967a",
|
|
||||||
"darkseagreen": "#8fbc8f",
|
|
||||||
"darkslateblue": "#483d8b",
|
|
||||||
"darkslategray": "#2f4f4f",
|
|
||||||
"darkslategrey": "#2f4f4f",
|
|
||||||
"darkturquoise": "#00ced1",
|
|
||||||
"darkviolet": "#9400d3",
|
|
||||||
"deeppink": "#ff1493",
|
|
||||||
"deepskyblue": "#00bfff",
|
|
||||||
"dimgray": "#696969",
|
|
||||||
"dimgrey": "#696969",
|
|
||||||
"dodgerblue": "#1e90ff",
|
|
||||||
"firebrick": "#b22222",
|
|
||||||
"floralwhite": "#fffaf0",
|
|
||||||
"forestgreen": "#228b22",
|
|
||||||
"fuchsia": "#ff00ff",
|
|
||||||
"gainsboro": "#dcdcdc",
|
|
||||||
"ghostwhite": "#f8f8ff",
|
|
||||||
"gold": "#ffd700",
|
|
||||||
"goldenrod": "#daa520",
|
|
||||||
"gray": "#808080",
|
|
||||||
"grey": "#808080",
|
|
||||||
"green": "#008000",
|
|
||||||
"greenyellow": "#adff2f",
|
|
||||||
"honeydew": "#f0fff0",
|
|
||||||
"hotpink": "#ff69b4",
|
|
||||||
"indianred": "#cd5c5c",
|
|
||||||
"indigo": "#4b0082",
|
|
||||||
"ivory": "#fffff0",
|
|
||||||
"khaki": "#f0e68c",
|
|
||||||
"lavender": "#e6e6fa",
|
|
||||||
"lavenderblush": "#fff0f5",
|
|
||||||
"lawngreen": "#7cfc00",
|
|
||||||
"lemonchiffon": "#fffacd",
|
|
||||||
"lightblue": "#add8e6",
|
|
||||||
"lightcoral": "#f08080",
|
|
||||||
"lightcyan": "#e0ffff",
|
|
||||||
"lightgoldenrodyellow": "#fafad2",
|
|
||||||
"lightgreen": "#90ee90",
|
|
||||||
"lightgray": "#d3d3d3",
|
|
||||||
"lightgrey": "#d3d3d3",
|
|
||||||
"lightpink": "#ffb6c1",
|
|
||||||
"lightsalmon": "#ffa07a",
|
|
||||||
"lightseagreen": "#20b2aa",
|
|
||||||
"lightskyblue": "#87cefa",
|
|
||||||
"lightslategray": "#778899",
|
|
||||||
"lightslategrey": "#778899",
|
|
||||||
"lightsteelblue": "#b0c4de",
|
|
||||||
"lightyellow": "#ffffe0",
|
|
||||||
"lime": "#00ff00",
|
|
||||||
"limegreen": "#32cd32",
|
|
||||||
"linen": "#faf0e6",
|
|
||||||
"magenta": "#ff00ff",
|
|
||||||
"maroon": "#800000",
|
|
||||||
"mediumaquamarine": "#66cdaa",
|
|
||||||
"mediumblue": "#0000cd",
|
|
||||||
"mediumorchid": "#ba55d3",
|
|
||||||
"mediumpurple": "#9370db",
|
|
||||||
"mediumseagreen": "#3cb371",
|
|
||||||
"mediumslateblue": "#7b68ee",
|
|
||||||
"mediumspringgreen": "#00fa9a",
|
|
||||||
"mediumturquoise": "#48d1cc",
|
|
||||||
"mediumvioletred": "#c71585",
|
|
||||||
"midnightblue": "#191970",
|
|
||||||
"mintcream": "#f5fffa",
|
|
||||||
"mistyrose": "#ffe4e1",
|
|
||||||
"moccasin": "#ffe4b5",
|
|
||||||
"navajowhite": "#ffdead",
|
|
||||||
"navy": "#000080",
|
|
||||||
"oldlace": "#fdf5e6",
|
|
||||||
"olive": "#808000",
|
|
||||||
"olivedrab": "#6b8e23",
|
|
||||||
"orange": "#ffa500",
|
|
||||||
"orangered": "#ff4500",
|
|
||||||
"orchid": "#da70d6",
|
|
||||||
"palegoldenrod": "#eee8aa",
|
|
||||||
"palegreen": "#98fb98",
|
|
||||||
"paleturquoise": "#afeeee",
|
|
||||||
"palevioletred": "#db7093",
|
|
||||||
"papayawhip": "#ffefd5",
|
|
||||||
"peachpuff": "#ffdab9",
|
|
||||||
"peru": "#cd853f",
|
|
||||||
"pink": "#ffc0cb",
|
|
||||||
"plum": "#dda0dd",
|
|
||||||
"powderblue": "#b0e0e6",
|
|
||||||
"purple": "#800080",
|
|
||||||
"rebeccapurple": "#663399",
|
|
||||||
"red": "#ff0000",
|
|
||||||
"rosybrown": "#bc8f8f",
|
|
||||||
"royalblue": "#4169e1",
|
|
||||||
"saddlebrown": "#8b4513",
|
|
||||||
"salmon": "#fa8072",
|
|
||||||
"sandybrown": "#f4a460",
|
|
||||||
"seagreen": "#2e8b57",
|
|
||||||
"seashell": "#fff5ee",
|
|
||||||
"sienna": "#a0522d",
|
|
||||||
"silver": "#c0c0c0",
|
|
||||||
"skyblue": "#87ceeb",
|
|
||||||
"slateblue": "#6a5acd",
|
|
||||||
"slategray": "#708090",
|
|
||||||
"slategrey": "#708090",
|
|
||||||
"snow": "#fffafa",
|
|
||||||
"springgreen": "#00ff7f",
|
|
||||||
"steelblue": "#4682b4",
|
|
||||||
"tan": "#d2b48c",
|
|
||||||
"teal": "#008080",
|
|
||||||
"thistle": "#d8bfd8",
|
|
||||||
"tomato": "#ff6347",
|
|
||||||
"turquoise": "#40e0d0",
|
|
||||||
"violet": "#ee82ee",
|
|
||||||
"wheat": "#f5deb3",
|
|
||||||
"white": "#ffffff",
|
|
||||||
"whitesmoke": "#f5f5f5",
|
|
||||||
"yellow": "#ffff00",
|
|
||||||
"yellowgreen": "#9acd32",
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,244 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# WCK-style drawing interface operations
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2003-12-07 fl created
|
|
||||||
# 2005-05-15 fl updated; added to PIL as ImageDraw2
|
|
||||||
# 2005-05-15 fl added text support
|
|
||||||
# 2005-05-20 fl added arc/chord/pieslice support
|
|
||||||
#
|
|
||||||
# Copyright (c) 2003-2005 by Secret Labs AB
|
|
||||||
# Copyright (c) 2003-2005 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
|
||||||
(Experimental) WCK-style drawing interface operations
|
|
||||||
|
|
||||||
.. seealso:: :py:mod:`PIL.ImageDraw`
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, AnyStr, BinaryIO
|
|
||||||
|
|
||||||
from . import Image, ImageColor, ImageDraw, ImageFont, ImagePath
|
|
||||||
from ._typing import Coords, StrOrBytesPath
|
|
||||||
|
|
||||||
|
|
||||||
class Pen:
|
|
||||||
"""Stores an outline color and width."""
|
|
||||||
|
|
||||||
def __init__(self, color: str, width: int = 1, opacity: int = 255) -> None:
|
|
||||||
self.color = ImageColor.getrgb(color)
|
|
||||||
self.width = width
|
|
||||||
|
|
||||||
|
|
||||||
class Brush:
|
|
||||||
"""Stores a fill color"""
|
|
||||||
|
|
||||||
def __init__(self, color: str, opacity: int = 255) -> None:
|
|
||||||
self.color = ImageColor.getrgb(color)
|
|
||||||
|
|
||||||
|
|
||||||
class Font:
|
|
||||||
"""Stores a TrueType font and color"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, color: str, file: StrOrBytesPath | BinaryIO, size: float = 12
|
|
||||||
) -> None:
|
|
||||||
# FIXME: add support for bitmap fonts
|
|
||||||
self.color = ImageColor.getrgb(color)
|
|
||||||
self.font = ImageFont.truetype(file, size)
|
|
||||||
|
|
||||||
|
|
||||||
class Draw:
|
|
||||||
"""
|
|
||||||
(Experimental) WCK-style drawing interface
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
image: Image.Image | str,
|
|
||||||
size: tuple[int, int] | list[int] | None = None,
|
|
||||||
color: float | tuple[float, ...] | str | None = None,
|
|
||||||
) -> None:
|
|
||||||
if isinstance(image, str):
|
|
||||||
if size is None:
|
|
||||||
msg = "If image argument is mode string, size must be a list or tuple"
|
|
||||||
raise ValueError(msg)
|
|
||||||
image = Image.new(image, size, color)
|
|
||||||
self.draw = ImageDraw.Draw(image)
|
|
||||||
self.image = image
|
|
||||||
self.transform: tuple[float, float, float, float, float, float] | None = None
|
|
||||||
|
|
||||||
def flush(self) -> Image.Image:
|
|
||||||
return self.image
|
|
||||||
|
|
||||||
def render(
|
|
||||||
self,
|
|
||||||
op: str,
|
|
||||||
xy: Coords,
|
|
||||||
pen: Pen | Brush | None,
|
|
||||||
brush: Brush | Pen | None = None,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> None:
|
|
||||||
# handle color arguments
|
|
||||||
outline = fill = None
|
|
||||||
width = 1
|
|
||||||
if isinstance(pen, Pen):
|
|
||||||
outline = pen.color
|
|
||||||
width = pen.width
|
|
||||||
elif isinstance(brush, Pen):
|
|
||||||
outline = brush.color
|
|
||||||
width = brush.width
|
|
||||||
if isinstance(brush, Brush):
|
|
||||||
fill = brush.color
|
|
||||||
elif isinstance(pen, Brush):
|
|
||||||
fill = pen.color
|
|
||||||
# handle transformation
|
|
||||||
if self.transform:
|
|
||||||
path = ImagePath.Path(xy)
|
|
||||||
path.transform(self.transform)
|
|
||||||
xy = path
|
|
||||||
# render the item
|
|
||||||
if op in ("arc", "line"):
|
|
||||||
kwargs.setdefault("fill", outline)
|
|
||||||
else:
|
|
||||||
kwargs.setdefault("fill", fill)
|
|
||||||
kwargs.setdefault("outline", outline)
|
|
||||||
if op == "line":
|
|
||||||
kwargs.setdefault("width", width)
|
|
||||||
getattr(self.draw, op)(xy, **kwargs)
|
|
||||||
|
|
||||||
def settransform(self, offset: tuple[float, float]) -> None:
|
|
||||||
"""Sets a transformation offset."""
|
|
||||||
xoffset, yoffset = offset
|
|
||||||
self.transform = (1, 0, xoffset, 0, 1, yoffset)
|
|
||||||
|
|
||||||
def arc(
|
|
||||||
self,
|
|
||||||
xy: Coords,
|
|
||||||
pen: Pen | Brush | None,
|
|
||||||
start: float,
|
|
||||||
end: float,
|
|
||||||
*options: Any,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Draws an arc (a portion of a circle outline) between the start and end
|
|
||||||
angles, inside the given bounding box.
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.arc`
|
|
||||||
"""
|
|
||||||
self.render("arc", xy, pen, *options, start=start, end=end)
|
|
||||||
|
|
||||||
def chord(
|
|
||||||
self,
|
|
||||||
xy: Coords,
|
|
||||||
pen: Pen | Brush | None,
|
|
||||||
start: float,
|
|
||||||
end: float,
|
|
||||||
*options: Any,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Same as :py:meth:`~PIL.ImageDraw2.Draw.arc`, but connects the end points
|
|
||||||
with a straight line.
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.chord`
|
|
||||||
"""
|
|
||||||
self.render("chord", xy, pen, *options, start=start, end=end)
|
|
||||||
|
|
||||||
def ellipse(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
|
|
||||||
"""
|
|
||||||
Draws an ellipse inside the given bounding box.
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.ellipse`
|
|
||||||
"""
|
|
||||||
self.render("ellipse", xy, pen, *options)
|
|
||||||
|
|
||||||
def line(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
|
|
||||||
"""
|
|
||||||
Draws a line between the coordinates in the ``xy`` list.
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.line`
|
|
||||||
"""
|
|
||||||
self.render("line", xy, pen, *options)
|
|
||||||
|
|
||||||
def pieslice(
|
|
||||||
self,
|
|
||||||
xy: Coords,
|
|
||||||
pen: Pen | Brush | None,
|
|
||||||
start: float,
|
|
||||||
end: float,
|
|
||||||
*options: Any,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Same as arc, but also draws straight lines between the end points and the
|
|
||||||
center of the bounding box.
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.pieslice`
|
|
||||||
"""
|
|
||||||
self.render("pieslice", xy, pen, *options, start=start, end=end)
|
|
||||||
|
|
||||||
def polygon(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
|
|
||||||
"""
|
|
||||||
Draws a polygon.
|
|
||||||
|
|
||||||
The polygon outline consists of straight lines between the given
|
|
||||||
coordinates, plus a straight line between the last and the first
|
|
||||||
coordinate.
|
|
||||||
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.polygon`
|
|
||||||
"""
|
|
||||||
self.render("polygon", xy, pen, *options)
|
|
||||||
|
|
||||||
def rectangle(self, xy: Coords, pen: Pen | Brush | None, *options: Any) -> None:
|
|
||||||
"""
|
|
||||||
Draws a rectangle.
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.rectangle`
|
|
||||||
"""
|
|
||||||
self.render("rectangle", xy, pen, *options)
|
|
||||||
|
|
||||||
def text(self, xy: tuple[float, float], text: AnyStr, font: Font) -> None:
|
|
||||||
"""
|
|
||||||
Draws the string at the given position.
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.text`
|
|
||||||
"""
|
|
||||||
if self.transform:
|
|
||||||
path = ImagePath.Path(xy)
|
|
||||||
path.transform(self.transform)
|
|
||||||
xy = path
|
|
||||||
self.draw.text(xy, text, font=font.font, fill=font.color)
|
|
||||||
|
|
||||||
def textbbox(
|
|
||||||
self, xy: tuple[float, float], text: AnyStr, font: Font
|
|
||||||
) -> tuple[float, float, float, float]:
|
|
||||||
"""
|
|
||||||
Returns bounding box (in pixels) of given text.
|
|
||||||
|
|
||||||
:return: ``(left, top, right, bottom)`` bounding box
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textbbox`
|
|
||||||
"""
|
|
||||||
if self.transform:
|
|
||||||
path = ImagePath.Path(xy)
|
|
||||||
path.transform(self.transform)
|
|
||||||
xy = path
|
|
||||||
return self.draw.textbbox(xy, text, font=font.font)
|
|
||||||
|
|
||||||
def textlength(self, text: AnyStr, font: Font) -> float:
|
|
||||||
"""
|
|
||||||
Returns length (in pixels) of given text.
|
|
||||||
This is the amount by which following text should be offset.
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`PIL.ImageDraw.ImageDraw.textlength`
|
|
||||||
"""
|
|
||||||
return self.draw.textlength(text, font=font.font)
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# image enhancement classes
|
|
||||||
#
|
|
||||||
# For a background, see "Image Processing By Interpolation and
|
|
||||||
# Extrapolation", Paul Haeberli and Douglas Voorhies. Available
|
|
||||||
# at http://www.graficaobscura.com/interp/index.html
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1996-03-23 fl Created
|
|
||||||
# 2009-06-16 fl Fixed mean calculation
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1996.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from . import Image, ImageFilter, ImageStat
|
|
||||||
|
|
||||||
|
|
||||||
class _Enhance:
|
|
||||||
image: Image.Image
|
|
||||||
degenerate: Image.Image
|
|
||||||
|
|
||||||
def enhance(self, factor: float) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Returns an enhanced image.
|
|
||||||
|
|
||||||
:param factor: A floating point value controlling the enhancement.
|
|
||||||
Factor 1.0 always returns a copy of the original image,
|
|
||||||
lower factors mean less color (brightness, contrast,
|
|
||||||
etc), and higher values more. There are no restrictions
|
|
||||||
on this value.
|
|
||||||
:rtype: :py:class:`~PIL.Image.Image`
|
|
||||||
"""
|
|
||||||
return Image.blend(self.degenerate, self.image, factor)
|
|
||||||
|
|
||||||
|
|
||||||
class Color(_Enhance):
|
|
||||||
"""Adjust image color balance.
|
|
||||||
|
|
||||||
This class can be used to adjust the colour balance of an image, in
|
|
||||||
a manner similar to the controls on a colour TV set. An enhancement
|
|
||||||
factor of 0.0 gives a black and white image. A factor of 1.0 gives
|
|
||||||
the original image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, image: Image.Image) -> None:
|
|
||||||
self.image = image
|
|
||||||
self.intermediate_mode = "L"
|
|
||||||
if "A" in image.getbands():
|
|
||||||
self.intermediate_mode = "LA"
|
|
||||||
|
|
||||||
if self.intermediate_mode != image.mode:
|
|
||||||
image = image.convert(self.intermediate_mode).convert(image.mode)
|
|
||||||
self.degenerate = image
|
|
||||||
|
|
||||||
|
|
||||||
class Contrast(_Enhance):
|
|
||||||
"""Adjust image contrast.
|
|
||||||
|
|
||||||
This class can be used to control the contrast of an image, similar
|
|
||||||
to the contrast control on a TV set. An enhancement factor of 0.0
|
|
||||||
gives a solid gray image. A factor of 1.0 gives the original image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, image: Image.Image) -> None:
|
|
||||||
self.image = image
|
|
||||||
if image.mode != "L":
|
|
||||||
image = image.convert("L")
|
|
||||||
mean = int(ImageStat.Stat(image).mean[0] + 0.5)
|
|
||||||
self.degenerate = Image.new("L", image.size, mean)
|
|
||||||
if self.degenerate.mode != self.image.mode:
|
|
||||||
self.degenerate = self.degenerate.convert(self.image.mode)
|
|
||||||
|
|
||||||
if "A" in self.image.getbands():
|
|
||||||
self.degenerate.putalpha(self.image.getchannel("A"))
|
|
||||||
|
|
||||||
|
|
||||||
class Brightness(_Enhance):
|
|
||||||
"""Adjust image brightness.
|
|
||||||
|
|
||||||
This class can be used to control the brightness of an image. An
|
|
||||||
enhancement factor of 0.0 gives a black image. A factor of 1.0 gives the
|
|
||||||
original image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, image: Image.Image) -> None:
|
|
||||||
self.image = image
|
|
||||||
self.degenerate = Image.new(image.mode, image.size, 0)
|
|
||||||
|
|
||||||
if "A" in image.getbands():
|
|
||||||
self.degenerate.putalpha(image.getchannel("A"))
|
|
||||||
|
|
||||||
|
|
||||||
class Sharpness(_Enhance):
|
|
||||||
"""Adjust image sharpness.
|
|
||||||
|
|
||||||
This class can be used to adjust the sharpness of an image. An
|
|
||||||
enhancement factor of 0.0 gives a blurred image, a factor of 1.0 gives the
|
|
||||||
original image, and a factor of 2.0 gives a sharpened image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, image: Image.Image) -> None:
|
|
||||||
self.image = image
|
|
||||||
self.degenerate = image.filter(ImageFilter.SMOOTH)
|
|
||||||
|
|
||||||
if "A" in image.getbands():
|
|
||||||
self.degenerate.putalpha(image.getchannel("A"))
|
|
||||||
@@ -1,935 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# base class for image file handlers
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 1995-09-09 fl Created
|
|
||||||
# 1996-03-11 fl Fixed load mechanism.
|
|
||||||
# 1996-04-15 fl Added pcx/xbm decoders.
|
|
||||||
# 1996-04-30 fl Added encoders.
|
|
||||||
# 1996-12-14 fl Added load helpers
|
|
||||||
# 1997-01-11 fl Use encode_to_file where possible
|
|
||||||
# 1997-08-27 fl Flush output in _save
|
|
||||||
# 1998-03-05 fl Use memory mapping for some modes
|
|
||||||
# 1999-02-04 fl Use memory mapping also for "I;16" and "I;16B"
|
|
||||||
# 1999-05-31 fl Added image parser
|
|
||||||
# 2000-10-12 fl Set readonly flag on memory-mapped images
|
|
||||||
# 2002-03-20 fl Use better messages for common decoder errors
|
|
||||||
# 2003-04-21 fl Fall back on mmap/map_buffer if map is not available
|
|
||||||
# 2003-10-30 fl Added StubImageFile class
|
|
||||||
# 2004-02-25 fl Made incremental parser more robust
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2004 by Secret Labs AB
|
|
||||||
# Copyright (c) 1995-2004 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import abc
|
|
||||||
import io
|
|
||||||
import itertools
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from typing import IO, Any, NamedTuple, cast
|
|
||||||
|
|
||||||
from . import ExifTags, Image
|
|
||||||
from ._util import DeferredError, is_path
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ._typing import StrOrBytesPath
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
MAXBLOCK = 65536
|
|
||||||
"""
|
|
||||||
By default, Pillow processes image data in blocks. This helps to prevent excessive use
|
|
||||||
of resources. Codecs may disable this behaviour with ``_pulls_fd`` or ``_pushes_fd``.
|
|
||||||
|
|
||||||
When reading an image, this is the number of bytes to read at once.
|
|
||||||
|
|
||||||
When writing an image, this is the number of bytes to write at once.
|
|
||||||
If the image width times 4 is greater, then that will be used instead.
|
|
||||||
Plugins may also set a greater number.
|
|
||||||
|
|
||||||
User code may set this to another number.
|
|
||||||
"""
|
|
||||||
|
|
||||||
SAFEBLOCK = 1024 * 1024
|
|
||||||
|
|
||||||
LOAD_TRUNCATED_IMAGES = False
|
|
||||||
"""Whether or not to load truncated image files. User code may change this."""
|
|
||||||
|
|
||||||
ERRORS = {
|
|
||||||
-1: "image buffer overrun error",
|
|
||||||
-2: "decoding error",
|
|
||||||
-3: "unknown error",
|
|
||||||
-8: "bad configuration",
|
|
||||||
-9: "out of memory error",
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
Dict of known error codes returned from :meth:`.PyDecoder.decode`,
|
|
||||||
:meth:`.PyEncoder.encode` :meth:`.PyEncoder.encode_to_pyfd` and
|
|
||||||
:meth:`.PyEncoder.encode_to_file`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
|
|
||||||
|
|
||||||
def _get_oserror(error: int, *, encoder: bool) -> OSError:
|
|
||||||
try:
|
|
||||||
msg = Image.core.getcodecstatus(error)
|
|
||||||
except AttributeError:
|
|
||||||
msg = ERRORS.get(error)
|
|
||||||
if not msg:
|
|
||||||
msg = f"{'encoder' if encoder else 'decoder'} error {error}"
|
|
||||||
msg += f" when {'writing' if encoder else 'reading'} image file"
|
|
||||||
return OSError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def _tilesort(t: _Tile) -> int:
|
|
||||||
# sort on offset
|
|
||||||
return t[2]
|
|
||||||
|
|
||||||
|
|
||||||
class _Tile(NamedTuple):
|
|
||||||
codec_name: str
|
|
||||||
extents: tuple[int, int, int, int] | None
|
|
||||||
offset: int = 0
|
|
||||||
args: tuple[Any, ...] | str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# ImageFile base class
|
|
||||||
|
|
||||||
|
|
||||||
class ImageFile(Image.Image):
|
|
||||||
"""Base class for image file format handlers."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, fp: StrOrBytesPath | IO[bytes], filename: str | bytes | None = None
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
self._min_frame = 0
|
|
||||||
|
|
||||||
self.custom_mimetype: str | None = None
|
|
||||||
|
|
||||||
self.tile: list[_Tile] = []
|
|
||||||
""" A list of tile descriptors """
|
|
||||||
|
|
||||||
self.readonly = 1 # until we know better
|
|
||||||
|
|
||||||
self.decoderconfig: tuple[Any, ...] = ()
|
|
||||||
self.decodermaxblock = MAXBLOCK
|
|
||||||
|
|
||||||
self.fp: IO[bytes] | None
|
|
||||||
self._fp: IO[bytes] | DeferredError
|
|
||||||
if is_path(fp):
|
|
||||||
# filename
|
|
||||||
self.fp = open(fp, "rb")
|
|
||||||
self.filename = os.fspath(fp)
|
|
||||||
self._exclusive_fp = True
|
|
||||||
else:
|
|
||||||
# stream
|
|
||||||
self.fp = cast(IO[bytes], fp)
|
|
||||||
self.filename = filename if filename is not None else ""
|
|
||||||
# can be overridden
|
|
||||||
self._exclusive_fp = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
self._open()
|
|
||||||
|
|
||||||
if isinstance(self, StubImageFile):
|
|
||||||
if loader := self._load():
|
|
||||||
loader.open(self)
|
|
||||||
except (
|
|
||||||
IndexError, # end of data
|
|
||||||
TypeError, # end of data (ord)
|
|
||||||
KeyError, # unsupported mode
|
|
||||||
EOFError, # got header but not the first frame
|
|
||||||
struct.error,
|
|
||||||
) as v:
|
|
||||||
raise SyntaxError(v) from v
|
|
||||||
|
|
||||||
if not self.mode or self.size[0] <= 0 or self.size[1] <= 0:
|
|
||||||
msg = "not identified by this driver"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
except BaseException:
|
|
||||||
# close the file only if we have opened it this constructor
|
|
||||||
if self._exclusive_fp:
|
|
||||||
self.fp.close()
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Context manager support
|
|
||||||
def __enter__(self) -> ImageFile:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def _close_fp(self) -> None:
|
|
||||||
if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError):
|
|
||||||
if self._fp != self.fp:
|
|
||||||
self._fp.close()
|
|
||||||
self._fp = DeferredError(ValueError("Operation on closed image"))
|
|
||||||
if self.fp:
|
|
||||||
self.fp.close()
|
|
||||||
|
|
||||||
def __exit__(self, *args: object) -> None:
|
|
||||||
if getattr(self, "_exclusive_fp", False):
|
|
||||||
self._close_fp()
|
|
||||||
self.fp = None
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""
|
|
||||||
Closes the file pointer, if possible.
|
|
||||||
|
|
||||||
This operation will destroy the image core and release its memory.
|
|
||||||
The image data will be unusable afterward.
|
|
||||||
|
|
||||||
This function is required to close images that have multiple frames or
|
|
||||||
have not had their file read and closed by the
|
|
||||||
:py:meth:`~PIL.Image.Image.load` method. See :ref:`file-handling` for
|
|
||||||
more information.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self._close_fp()
|
|
||||||
self.fp = None
|
|
||||||
except Exception as msg:
|
|
||||||
logger.debug("Error closing: %s", msg)
|
|
||||||
|
|
||||||
super().close()
|
|
||||||
|
|
||||||
def get_child_images(self) -> list[ImageFile]:
|
|
||||||
child_images = []
|
|
||||||
exif = self.getexif()
|
|
||||||
ifds = []
|
|
||||||
if ExifTags.Base.SubIFDs in exif:
|
|
||||||
subifd_offsets = exif[ExifTags.Base.SubIFDs]
|
|
||||||
if subifd_offsets:
|
|
||||||
if not isinstance(subifd_offsets, tuple):
|
|
||||||
subifd_offsets = (subifd_offsets,)
|
|
||||||
ifds = [
|
|
||||||
(exif._get_ifd_dict(subifd_offset), subifd_offset)
|
|
||||||
for subifd_offset in subifd_offsets
|
|
||||||
]
|
|
||||||
ifd1 = exif.get_ifd(ExifTags.IFD.IFD1)
|
|
||||||
if ifd1 and ifd1.get(ExifTags.Base.JpegIFOffset):
|
|
||||||
assert exif._info is not None
|
|
||||||
ifds.append((ifd1, exif._info.next))
|
|
||||||
|
|
||||||
offset = None
|
|
||||||
for ifd, ifd_offset in ifds:
|
|
||||||
assert self.fp is not None
|
|
||||||
current_offset = self.fp.tell()
|
|
||||||
if offset is None:
|
|
||||||
offset = current_offset
|
|
||||||
|
|
||||||
fp = self.fp
|
|
||||||
if ifd is not None:
|
|
||||||
thumbnail_offset = ifd.get(ExifTags.Base.JpegIFOffset)
|
|
||||||
if thumbnail_offset is not None:
|
|
||||||
thumbnail_offset += getattr(self, "_exif_offset", 0)
|
|
||||||
self.fp.seek(thumbnail_offset)
|
|
||||||
|
|
||||||
length = ifd.get(ExifTags.Base.JpegIFByteCount)
|
|
||||||
assert isinstance(length, int)
|
|
||||||
data = self.fp.read(length)
|
|
||||||
fp = io.BytesIO(data)
|
|
||||||
|
|
||||||
with Image.open(fp) as im:
|
|
||||||
from . import TiffImagePlugin
|
|
||||||
|
|
||||||
if thumbnail_offset is None and isinstance(
|
|
||||||
im, TiffImagePlugin.TiffImageFile
|
|
||||||
):
|
|
||||||
im._frame_pos = [ifd_offset]
|
|
||||||
im._seek(0)
|
|
||||||
im.load()
|
|
||||||
child_images.append(im)
|
|
||||||
|
|
||||||
if offset is not None:
|
|
||||||
assert self.fp is not None
|
|
||||||
self.fp.seek(offset)
|
|
||||||
return child_images
|
|
||||||
|
|
||||||
def get_format_mimetype(self) -> str | None:
|
|
||||||
if self.custom_mimetype:
|
|
||||||
return self.custom_mimetype
|
|
||||||
if self.format is not None:
|
|
||||||
return Image.MIME.get(self.format.upper())
|
|
||||||
return None
|
|
||||||
|
|
||||||
def __getstate__(self) -> list[Any]:
|
|
||||||
return super().__getstate__() + [self.filename]
|
|
||||||
|
|
||||||
def __setstate__(self, state: list[Any]) -> None:
|
|
||||||
self.tile = []
|
|
||||||
if len(state) > 5:
|
|
||||||
self.filename = state[5]
|
|
||||||
super().__setstate__(state)
|
|
||||||
|
|
||||||
def verify(self) -> None:
|
|
||||||
"""Check file integrity"""
|
|
||||||
|
|
||||||
# raise exception if something's wrong. must be called
|
|
||||||
# directly after open, and closes file when finished.
|
|
||||||
if self._exclusive_fp and self.fp:
|
|
||||||
self.fp.close()
|
|
||||||
self.fp = None
|
|
||||||
|
|
||||||
def load(self) -> Image.core.PixelAccess | None:
|
|
||||||
"""Load image data based on tile list"""
|
|
||||||
|
|
||||||
if not self.tile and self._im is None:
|
|
||||||
msg = "cannot load this image"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
pixel = Image.Image.load(self)
|
|
||||||
if not self.tile:
|
|
||||||
return pixel
|
|
||||||
|
|
||||||
self.map: mmap.mmap | None = None
|
|
||||||
use_mmap = self.filename and len(self.tile) == 1
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
readonly = 0
|
|
||||||
|
|
||||||
# look for read/seek overrides
|
|
||||||
if hasattr(self, "load_read"):
|
|
||||||
read = self.load_read
|
|
||||||
# don't use mmap if there are custom read/seek functions
|
|
||||||
use_mmap = False
|
|
||||||
else:
|
|
||||||
read = self.fp.read
|
|
||||||
|
|
||||||
if hasattr(self, "load_seek"):
|
|
||||||
seek = self.load_seek
|
|
||||||
use_mmap = False
|
|
||||||
else:
|
|
||||||
seek = self.fp.seek
|
|
||||||
|
|
||||||
if use_mmap:
|
|
||||||
# try memory mapping
|
|
||||||
decoder_name, extents, offset, args = self.tile[0]
|
|
||||||
if isinstance(args, str):
|
|
||||||
args = (args, 0, 1)
|
|
||||||
if (
|
|
||||||
decoder_name == "raw"
|
|
||||||
and isinstance(args, tuple)
|
|
||||||
and len(args) >= 3
|
|
||||||
and args[0] == self.mode
|
|
||||||
and args[0] in Image._MAPMODES
|
|
||||||
):
|
|
||||||
if offset < 0:
|
|
||||||
msg = "Tile offset cannot be negative"
|
|
||||||
raise ValueError(msg)
|
|
||||||
try:
|
|
||||||
# use mmap, if possible
|
|
||||||
import mmap
|
|
||||||
|
|
||||||
with open(self.filename) as fp:
|
|
||||||
self.map = mmap.mmap(fp.fileno(), 0, access=mmap.ACCESS_READ)
|
|
||||||
if offset + self.size[1] * args[1] > self.map.size():
|
|
||||||
msg = "buffer is not large enough"
|
|
||||||
raise OSError(msg)
|
|
||||||
self.im = Image.core.map_buffer(
|
|
||||||
self.map, self.size, decoder_name, offset, args
|
|
||||||
)
|
|
||||||
readonly = 1
|
|
||||||
# After trashing self.im,
|
|
||||||
# we might need to reload the palette data.
|
|
||||||
if self.palette:
|
|
||||||
self.palette.dirty = 1
|
|
||||||
except (AttributeError, OSError, ImportError):
|
|
||||||
self.map = None
|
|
||||||
|
|
||||||
self.load_prepare()
|
|
||||||
err_code = -3 # initialize to unknown error
|
|
||||||
if not self.map:
|
|
||||||
# sort tiles in file order
|
|
||||||
self.tile.sort(key=_tilesort)
|
|
||||||
|
|
||||||
# FIXME: This is a hack to handle TIFF's JpegTables tag.
|
|
||||||
prefix = getattr(self, "tile_prefix", b"")
|
|
||||||
|
|
||||||
# Remove consecutive duplicates that only differ by their offset
|
|
||||||
self.tile = [
|
|
||||||
list(tiles)[-1]
|
|
||||||
for _, tiles in itertools.groupby(
|
|
||||||
self.tile, lambda tile: (tile[0], tile[1], tile[3])
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for i, (decoder_name, extents, offset, args) in enumerate(self.tile):
|
|
||||||
seek(offset)
|
|
||||||
decoder = Image._getdecoder(
|
|
||||||
self.mode, decoder_name, args, self.decoderconfig
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
decoder.setimage(self.im, extents)
|
|
||||||
if decoder.pulls_fd:
|
|
||||||
decoder.setfd(self.fp)
|
|
||||||
err_code = decoder.decode(b"")[1]
|
|
||||||
else:
|
|
||||||
b = prefix
|
|
||||||
while True:
|
|
||||||
read_bytes = self.decodermaxblock
|
|
||||||
if i + 1 < len(self.tile):
|
|
||||||
next_offset = self.tile[i + 1].offset
|
|
||||||
if next_offset > offset:
|
|
||||||
read_bytes = next_offset - offset
|
|
||||||
try:
|
|
||||||
s = read(read_bytes)
|
|
||||||
except (IndexError, struct.error) as e:
|
|
||||||
# truncated png/gif
|
|
||||||
if LOAD_TRUNCATED_IMAGES:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
msg = "image file is truncated"
|
|
||||||
raise OSError(msg) from e
|
|
||||||
|
|
||||||
if not s: # truncated jpeg
|
|
||||||
if LOAD_TRUNCATED_IMAGES:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
msg = (
|
|
||||||
"image file is truncated "
|
|
||||||
f"({len(b)} bytes not processed)"
|
|
||||||
)
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
b = b + s
|
|
||||||
n, err_code = decoder.decode(b)
|
|
||||||
if n < 0:
|
|
||||||
break
|
|
||||||
b = b[n:]
|
|
||||||
finally:
|
|
||||||
# Need to cleanup here to prevent leaks
|
|
||||||
decoder.cleanup()
|
|
||||||
|
|
||||||
self.tile = []
|
|
||||||
self.readonly = readonly
|
|
||||||
|
|
||||||
self.load_end()
|
|
||||||
|
|
||||||
if self._exclusive_fp and self._close_exclusive_fp_after_loading:
|
|
||||||
self.fp.close()
|
|
||||||
self.fp = None
|
|
||||||
|
|
||||||
if not self.map and not LOAD_TRUNCATED_IMAGES and err_code < 0:
|
|
||||||
# still raised if decoder fails to return anything
|
|
||||||
raise _get_oserror(err_code, encoder=False)
|
|
||||||
|
|
||||||
return Image.Image.load(self)
|
|
||||||
|
|
||||||
def load_prepare(self) -> None:
|
|
||||||
# create image memory if necessary
|
|
||||||
if self._im is None:
|
|
||||||
self.im = Image.core.new(self.mode, self.size)
|
|
||||||
# create palette (optional)
|
|
||||||
if self.mode == "P":
|
|
||||||
Image.Image.load(self)
|
|
||||||
|
|
||||||
def load_end(self) -> None:
|
|
||||||
# may be overridden
|
|
||||||
pass
|
|
||||||
|
|
||||||
# may be defined for contained formats
|
|
||||||
# def load_seek(self, pos: int) -> None:
|
|
||||||
# pass
|
|
||||||
|
|
||||||
# may be defined for blocked formats (e.g. PNG)
|
|
||||||
# def load_read(self, read_bytes: int) -> bytes:
|
|
||||||
# pass
|
|
||||||
|
|
||||||
def _seek_check(self, frame: int) -> bool:
|
|
||||||
if (
|
|
||||||
frame < self._min_frame
|
|
||||||
# Only check upper limit on frames if additional seek operations
|
|
||||||
# are not required to do so
|
|
||||||
or (
|
|
||||||
not (hasattr(self, "_n_frames") and self._n_frames is None)
|
|
||||||
and frame >= getattr(self, "n_frames") + self._min_frame
|
|
||||||
)
|
|
||||||
):
|
|
||||||
msg = "attempt to seek outside sequence"
|
|
||||||
raise EOFError(msg)
|
|
||||||
|
|
||||||
return self.tell() != frame
|
|
||||||
|
|
||||||
|
|
||||||
class StubHandler(abc.ABC):
|
|
||||||
def open(self, im: StubImageFile) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def load(self, im: StubImageFile) -> Image.Image:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class StubImageFile(ImageFile, metaclass=abc.ABCMeta):
|
|
||||||
"""
|
|
||||||
Base class for stub image loaders.
|
|
||||||
|
|
||||||
A stub loader is an image loader that can identify files of a
|
|
||||||
certain format, but relies on external code to load the file.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def _open(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def load(self) -> Image.core.PixelAccess | None:
|
|
||||||
loader = self._load()
|
|
||||||
if loader is None:
|
|
||||||
msg = f"cannot find loader for this {self.format} file"
|
|
||||||
raise OSError(msg)
|
|
||||||
image = loader.load(self)
|
|
||||||
assert image is not None
|
|
||||||
# become the other object (!)
|
|
||||||
self.__class__ = image.__class__ # type: ignore[assignment]
|
|
||||||
self.__dict__ = image.__dict__
|
|
||||||
return image.load()
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def _load(self) -> StubHandler | None:
|
|
||||||
"""(Hook) Find actual image loader."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Parser:
|
|
||||||
"""
|
|
||||||
Incremental image parser. This class implements the standard
|
|
||||||
feed/close consumer interface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
incremental = None
|
|
||||||
image: Image.Image | None = None
|
|
||||||
data: bytes | None = None
|
|
||||||
decoder: Image.core.ImagingDecoder | PyDecoder | None = None
|
|
||||||
offset = 0
|
|
||||||
finished = 0
|
|
||||||
|
|
||||||
def reset(self) -> None:
|
|
||||||
"""
|
|
||||||
(Consumer) Reset the parser. Note that you can only call this
|
|
||||||
method immediately after you've created a parser; parser
|
|
||||||
instances cannot be reused.
|
|
||||||
"""
|
|
||||||
assert self.data is None, "cannot reuse parsers"
|
|
||||||
|
|
||||||
def feed(self, data: bytes) -> None:
|
|
||||||
"""
|
|
||||||
(Consumer) Feed data to the parser.
|
|
||||||
|
|
||||||
:param data: A string buffer.
|
|
||||||
:exception OSError: If the parser failed to parse the image file.
|
|
||||||
"""
|
|
||||||
# collect data
|
|
||||||
|
|
||||||
if self.finished:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.data is None:
|
|
||||||
self.data = data
|
|
||||||
else:
|
|
||||||
self.data = self.data + data
|
|
||||||
|
|
||||||
# parse what we have
|
|
||||||
if self.decoder:
|
|
||||||
if self.offset > 0:
|
|
||||||
# skip header
|
|
||||||
skip = min(len(self.data), self.offset)
|
|
||||||
self.data = self.data[skip:]
|
|
||||||
self.offset = self.offset - skip
|
|
||||||
if self.offset > 0 or not self.data:
|
|
||||||
return
|
|
||||||
|
|
||||||
n, e = self.decoder.decode(self.data)
|
|
||||||
|
|
||||||
if n < 0:
|
|
||||||
# end of stream
|
|
||||||
self.data = None
|
|
||||||
self.finished = 1
|
|
||||||
if e < 0:
|
|
||||||
# decoding error
|
|
||||||
self.image = None
|
|
||||||
raise _get_oserror(e, encoder=False)
|
|
||||||
else:
|
|
||||||
# end of image
|
|
||||||
return
|
|
||||||
self.data = self.data[n:]
|
|
||||||
|
|
||||||
elif self.image:
|
|
||||||
# if we end up here with no decoder, this file cannot
|
|
||||||
# be incrementally parsed. wait until we've gotten all
|
|
||||||
# available data
|
|
||||||
pass
|
|
||||||
|
|
||||||
else:
|
|
||||||
# attempt to open this file
|
|
||||||
try:
|
|
||||||
with io.BytesIO(self.data) as fp:
|
|
||||||
im = Image.open(fp)
|
|
||||||
except OSError:
|
|
||||||
pass # not enough data
|
|
||||||
else:
|
|
||||||
flag = hasattr(im, "load_seek") or hasattr(im, "load_read")
|
|
||||||
if not flag and len(im.tile) == 1:
|
|
||||||
# initialize decoder
|
|
||||||
im.load_prepare()
|
|
||||||
d, e, o, a = im.tile[0]
|
|
||||||
im.tile = []
|
|
||||||
self.decoder = Image._getdecoder(im.mode, d, a, im.decoderconfig)
|
|
||||||
self.decoder.setimage(im.im, e)
|
|
||||||
|
|
||||||
# calculate decoder offset
|
|
||||||
self.offset = o
|
|
||||||
if self.offset <= len(self.data):
|
|
||||||
self.data = self.data[self.offset :]
|
|
||||||
self.offset = 0
|
|
||||||
|
|
||||||
self.image = im
|
|
||||||
|
|
||||||
def __enter__(self) -> Parser:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, *args: object) -> None:
|
|
||||||
self.close()
|
|
||||||
|
|
||||||
def close(self) -> Image.Image:
|
|
||||||
"""
|
|
||||||
(Consumer) Close the stream.
|
|
||||||
|
|
||||||
:returns: An image object.
|
|
||||||
:exception OSError: If the parser failed to parse the image file either
|
|
||||||
because it cannot be identified or cannot be
|
|
||||||
decoded.
|
|
||||||
"""
|
|
||||||
# finish decoding
|
|
||||||
if self.decoder:
|
|
||||||
# get rid of what's left in the buffers
|
|
||||||
self.feed(b"")
|
|
||||||
self.data = self.decoder = None
|
|
||||||
if not self.finished:
|
|
||||||
msg = "image was incomplete"
|
|
||||||
raise OSError(msg)
|
|
||||||
if not self.image:
|
|
||||||
msg = "cannot parse this image"
|
|
||||||
raise OSError(msg)
|
|
||||||
if self.data:
|
|
||||||
# incremental parsing not possible; reopen the file
|
|
||||||
# not that we have all data
|
|
||||||
with io.BytesIO(self.data) as fp:
|
|
||||||
try:
|
|
||||||
self.image = Image.open(fp)
|
|
||||||
finally:
|
|
||||||
self.image.load()
|
|
||||||
return self.image
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], tile: list[_Tile], bufsize: int = 0) -> None:
|
|
||||||
"""Helper to save image based on tile list
|
|
||||||
|
|
||||||
:param im: Image object.
|
|
||||||
:param fp: File object.
|
|
||||||
:param tile: Tile list.
|
|
||||||
:param bufsize: Optional buffer size
|
|
||||||
"""
|
|
||||||
|
|
||||||
im.load()
|
|
||||||
if not hasattr(im, "encoderconfig"):
|
|
||||||
im.encoderconfig = ()
|
|
||||||
tile.sort(key=_tilesort)
|
|
||||||
# FIXME: make MAXBLOCK a configuration parameter
|
|
||||||
# It would be great if we could have the encoder specify what it needs
|
|
||||||
# But, it would need at least the image size in most cases. RawEncode is
|
|
||||||
# a tricky case.
|
|
||||||
bufsize = max(MAXBLOCK, bufsize, im.size[0] * 4) # see RawEncode.c
|
|
||||||
try:
|
|
||||||
fh = fp.fileno()
|
|
||||||
fp.flush()
|
|
||||||
_encode_tile(im, fp, tile, bufsize, fh)
|
|
||||||
except (AttributeError, io.UnsupportedOperation) as exc:
|
|
||||||
_encode_tile(im, fp, tile, bufsize, None, exc)
|
|
||||||
if hasattr(fp, "flush"):
|
|
||||||
fp.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_tile(
|
|
||||||
im: Image.Image,
|
|
||||||
fp: IO[bytes],
|
|
||||||
tile: list[_Tile],
|
|
||||||
bufsize: int,
|
|
||||||
fh: int | None,
|
|
||||||
exc: BaseException | None = None,
|
|
||||||
) -> None:
|
|
||||||
for encoder_name, extents, offset, args in tile:
|
|
||||||
if offset > 0:
|
|
||||||
fp.seek(offset)
|
|
||||||
encoder = Image._getencoder(im.mode, encoder_name, args, im.encoderconfig)
|
|
||||||
try:
|
|
||||||
encoder.setimage(im.im, extents)
|
|
||||||
if encoder.pushes_fd:
|
|
||||||
encoder.setfd(fp)
|
|
||||||
errcode = encoder.encode_to_pyfd()[1]
|
|
||||||
else:
|
|
||||||
if exc:
|
|
||||||
# compress to Python file-compatible object
|
|
||||||
while True:
|
|
||||||
errcode, data = encoder.encode(bufsize)[1:]
|
|
||||||
fp.write(data)
|
|
||||||
if errcode:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
# slight speedup: compress to real file object
|
|
||||||
assert fh is not None
|
|
||||||
errcode = encoder.encode_to_file(fh, bufsize)
|
|
||||||
if errcode < 0:
|
|
||||||
raise _get_oserror(errcode, encoder=True) from exc
|
|
||||||
finally:
|
|
||||||
encoder.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_read(fp: IO[bytes], size: int) -> bytes:
|
|
||||||
"""
|
|
||||||
Reads large blocks in a safe way. Unlike fp.read(n), this function
|
|
||||||
doesn't trust the user. If the requested size is larger than
|
|
||||||
SAFEBLOCK, the file is read block by block.
|
|
||||||
|
|
||||||
:param fp: File handle. Must implement a <b>read</b> method.
|
|
||||||
:param size: Number of bytes to read.
|
|
||||||
:returns: A string containing <i>size</i> bytes of data.
|
|
||||||
|
|
||||||
Raises an OSError if the file is truncated and the read cannot be completed
|
|
||||||
|
|
||||||
"""
|
|
||||||
if size <= 0:
|
|
||||||
return b""
|
|
||||||
if size <= SAFEBLOCK:
|
|
||||||
data = fp.read(size)
|
|
||||||
if len(data) < size:
|
|
||||||
msg = "Truncated File Read"
|
|
||||||
raise OSError(msg)
|
|
||||||
return data
|
|
||||||
blocks: list[bytes] = []
|
|
||||||
remaining_size = size
|
|
||||||
while remaining_size > 0:
|
|
||||||
block = fp.read(min(remaining_size, SAFEBLOCK))
|
|
||||||
if not block:
|
|
||||||
break
|
|
||||||
blocks.append(block)
|
|
||||||
remaining_size -= len(block)
|
|
||||||
if sum(len(block) for block in blocks) < size:
|
|
||||||
msg = "Truncated File Read"
|
|
||||||
raise OSError(msg)
|
|
||||||
return b"".join(blocks)
|
|
||||||
|
|
||||||
|
|
||||||
class PyCodecState:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.xsize = 0
|
|
||||||
self.ysize = 0
|
|
||||||
self.xoff = 0
|
|
||||||
self.yoff = 0
|
|
||||||
|
|
||||||
def extents(self) -> tuple[int, int, int, int]:
|
|
||||||
return self.xoff, self.yoff, self.xoff + self.xsize, self.yoff + self.ysize
|
|
||||||
|
|
||||||
|
|
||||||
class PyCodec:
|
|
||||||
fd: IO[bytes] | None
|
|
||||||
|
|
||||||
def __init__(self, mode: str, *args: Any) -> None:
|
|
||||||
self.im: Image.core.ImagingCore | None = None
|
|
||||||
self.state = PyCodecState()
|
|
||||||
self.fd = None
|
|
||||||
self.mode = mode
|
|
||||||
self.init(args)
|
|
||||||
|
|
||||||
def init(self, args: tuple[Any, ...]) -> None:
|
|
||||||
"""
|
|
||||||
Override to perform codec specific initialization
|
|
||||||
|
|
||||||
:param args: Tuple of arg items from the tile entry
|
|
||||||
:returns: None
|
|
||||||
"""
|
|
||||||
self.args = args
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
"""
|
|
||||||
Override to perform codec specific cleanup
|
|
||||||
|
|
||||||
:returns: None
|
|
||||||
"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
def setfd(self, fd: IO[bytes]) -> None:
|
|
||||||
"""
|
|
||||||
Called from ImageFile to set the Python file-like object
|
|
||||||
|
|
||||||
:param fd: A Python file-like object
|
|
||||||
:returns: None
|
|
||||||
"""
|
|
||||||
self.fd = fd
|
|
||||||
|
|
||||||
def setimage(
|
|
||||||
self,
|
|
||||||
im: Image.core.ImagingCore,
|
|
||||||
extents: tuple[int, int, int, int] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Called from ImageFile to set the core output image for the codec
|
|
||||||
|
|
||||||
:param im: A core image object
|
|
||||||
:param extents: a 4 tuple of (x0, y0, x1, y1) defining the rectangle
|
|
||||||
for this tile
|
|
||||||
:returns: None
|
|
||||||
"""
|
|
||||||
|
|
||||||
# following c code
|
|
||||||
self.im = im
|
|
||||||
|
|
||||||
if extents:
|
|
||||||
x0, y0, x1, y1 = extents
|
|
||||||
|
|
||||||
if x0 < 0 or y0 < 0 or x1 > self.im.size[0] or y1 > self.im.size[1]:
|
|
||||||
msg = "Tile cannot extend outside image"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
self.state.xoff = x0
|
|
||||||
self.state.yoff = y0
|
|
||||||
self.state.xsize = x1 - x0
|
|
||||||
self.state.ysize = y1 - y0
|
|
||||||
else:
|
|
||||||
self.state.xsize, self.state.ysize = self.im.size
|
|
||||||
|
|
||||||
if self.state.xsize <= 0 or self.state.ysize <= 0:
|
|
||||||
msg = "Size must be positive"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
class PyDecoder(PyCodec):
|
|
||||||
"""
|
|
||||||
Python implementation of a format decoder. Override this class and
|
|
||||||
add the decoding logic in the :meth:`decode` method.
|
|
||||||
|
|
||||||
See :ref:`Writing Your Own File Codec in Python<file-codecs-py>`
|
|
||||||
"""
|
|
||||||
|
|
||||||
_pulls_fd = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pulls_fd(self) -> bool:
|
|
||||||
return self._pulls_fd
|
|
||||||
|
|
||||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
|
||||||
"""
|
|
||||||
Override to perform the decoding process.
|
|
||||||
|
|
||||||
:param buffer: A bytes object with the data to be decoded.
|
|
||||||
:returns: A tuple of ``(bytes consumed, errcode)``.
|
|
||||||
If finished with decoding return -1 for the bytes consumed.
|
|
||||||
Err codes are from :data:`.ImageFile.ERRORS`.
|
|
||||||
"""
|
|
||||||
msg = "unavailable in base decoder"
|
|
||||||
raise NotImplementedError(msg)
|
|
||||||
|
|
||||||
def set_as_raw(
|
|
||||||
self, data: bytes, rawmode: str | None = None, extra: tuple[Any, ...] = ()
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Convenience method to set the internal image from a stream of raw data
|
|
||||||
|
|
||||||
:param data: Bytes to be set
|
|
||||||
:param rawmode: The rawmode to be used for the decoder.
|
|
||||||
If not specified, it will default to the mode of the image
|
|
||||||
:param extra: Extra arguments for the decoder.
|
|
||||||
:returns: None
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not rawmode:
|
|
||||||
rawmode = self.mode
|
|
||||||
d = Image._getdecoder(self.mode, "raw", rawmode, extra)
|
|
||||||
assert self.im is not None
|
|
||||||
d.setimage(self.im, self.state.extents())
|
|
||||||
s = d.decode(data)
|
|
||||||
|
|
||||||
if s[0] >= 0:
|
|
||||||
msg = "not enough image data"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if s[1] != 0:
|
|
||||||
msg = "cannot decode image data"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
class PyEncoder(PyCodec):
|
|
||||||
"""
|
|
||||||
Python implementation of a format encoder. Override this class and
|
|
||||||
add the decoding logic in the :meth:`encode` method.
|
|
||||||
|
|
||||||
See :ref:`Writing Your Own File Codec in Python<file-codecs-py>`
|
|
||||||
"""
|
|
||||||
|
|
||||||
_pushes_fd = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def pushes_fd(self) -> bool:
|
|
||||||
return self._pushes_fd
|
|
||||||
|
|
||||||
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
|
|
||||||
"""
|
|
||||||
Override to perform the encoding process.
|
|
||||||
|
|
||||||
:param bufsize: Buffer size.
|
|
||||||
:returns: A tuple of ``(bytes encoded, errcode, bytes)``.
|
|
||||||
If finished with encoding return 1 for the error code.
|
|
||||||
Err codes are from :data:`.ImageFile.ERRORS`.
|
|
||||||
"""
|
|
||||||
msg = "unavailable in base encoder"
|
|
||||||
raise NotImplementedError(msg)
|
|
||||||
|
|
||||||
def encode_to_pyfd(self) -> tuple[int, int]:
|
|
||||||
"""
|
|
||||||
If ``pushes_fd`` is ``True``, then this method will be used,
|
|
||||||
and ``encode()`` will only be called once.
|
|
||||||
|
|
||||||
:returns: A tuple of ``(bytes consumed, errcode)``.
|
|
||||||
Err codes are from :data:`.ImageFile.ERRORS`.
|
|
||||||
"""
|
|
||||||
if not self.pushes_fd:
|
|
||||||
return 0, -8 # bad configuration
|
|
||||||
bytes_consumed, errcode, data = self.encode(0)
|
|
||||||
if data:
|
|
||||||
assert self.fd is not None
|
|
||||||
self.fd.write(data)
|
|
||||||
return bytes_consumed, errcode
|
|
||||||
|
|
||||||
def encode_to_file(self, fh: int, bufsize: int) -> int:
|
|
||||||
"""
|
|
||||||
:param fh: File handle.
|
|
||||||
:param bufsize: Buffer size.
|
|
||||||
|
|
||||||
:returns: If finished successfully, return 0.
|
|
||||||
Otherwise, return an error code. Err codes are from
|
|
||||||
:data:`.ImageFile.ERRORS`.
|
|
||||||
"""
|
|
||||||
errcode = 0
|
|
||||||
while errcode == 0:
|
|
||||||
status, errcode, buf = self.encode(bufsize)
|
|
||||||
if status > 0:
|
|
||||||
os.write(fh, buf[status:])
|
|
||||||
return errcode
|
|
||||||
@@ -1,607 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# standard filters
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1995-11-27 fl Created
|
|
||||||
# 2002-06-08 fl Added rank and mode filters
|
|
||||||
# 2003-09-15 fl Fixed rank calculation in rank filter; added expand call
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
|
||||||
# Copyright (c) 1995-2002 by Fredrik Lundh.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import abc
|
|
||||||
import functools
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
from types import ModuleType
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from . import _imaging
|
|
||||||
from ._typing import NumpyArray
|
|
||||||
|
|
||||||
|
|
||||||
class Filter(abc.ABC):
|
|
||||||
@abc.abstractmethod
|
|
||||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class MultibandFilter(Filter):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class BuiltinFilter(MultibandFilter):
|
|
||||||
filterargs: tuple[Any, ...]
|
|
||||||
|
|
||||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
|
||||||
if image.mode == "P":
|
|
||||||
msg = "cannot filter palette images"
|
|
||||||
raise ValueError(msg)
|
|
||||||
return image.filter(*self.filterargs)
|
|
||||||
|
|
||||||
|
|
||||||
class Kernel(BuiltinFilter):
|
|
||||||
"""
|
|
||||||
Create a convolution kernel. This only supports 3x3 and 5x5 integer and floating
|
|
||||||
point kernels.
|
|
||||||
|
|
||||||
Kernels can only be applied to "L" and "RGB" images.
|
|
||||||
|
|
||||||
:param size: Kernel size, given as (width, height). This must be (3,3) or (5,5).
|
|
||||||
:param kernel: A sequence containing kernel weights. The kernel will be flipped
|
|
||||||
vertically before being applied to the image.
|
|
||||||
:param scale: Scale factor. If given, the result for each pixel is divided by this
|
|
||||||
value. The default is the sum of the kernel weights.
|
|
||||||
:param offset: Offset. If given, this value is added to the result, after it has
|
|
||||||
been divided by the scale factor.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "Kernel"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
size: tuple[int, int],
|
|
||||||
kernel: Sequence[float],
|
|
||||||
scale: float | None = None,
|
|
||||||
offset: float = 0,
|
|
||||||
) -> None:
|
|
||||||
if scale is None:
|
|
||||||
# default scale is sum of kernel
|
|
||||||
scale = functools.reduce(lambda a, b: a + b, kernel)
|
|
||||||
if size[0] * size[1] != len(kernel):
|
|
||||||
msg = "not enough coefficients in kernel"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.filterargs = size, scale, offset, kernel
|
|
||||||
|
|
||||||
|
|
||||||
class RankFilter(Filter):
|
|
||||||
"""
|
|
||||||
Create a rank filter. The rank filter sorts all pixels in
|
|
||||||
a window of the given size, and returns the ``rank``'th value.
|
|
||||||
|
|
||||||
:param size: The kernel size, in pixels.
|
|
||||||
:param rank: What pixel value to pick. Use 0 for a min filter,
|
|
||||||
``size * size / 2`` for a median filter, ``size * size - 1``
|
|
||||||
for a max filter, etc.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "Rank"
|
|
||||||
|
|
||||||
def __init__(self, size: int, rank: int) -> None:
|
|
||||||
self.size = size
|
|
||||||
self.rank = rank
|
|
||||||
|
|
||||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
|
||||||
if image.mode == "P":
|
|
||||||
msg = "cannot filter palette images"
|
|
||||||
raise ValueError(msg)
|
|
||||||
image = image.expand(self.size // 2, self.size // 2)
|
|
||||||
return image.rankfilter(self.size, self.rank)
|
|
||||||
|
|
||||||
|
|
||||||
class MedianFilter(RankFilter):
|
|
||||||
"""
|
|
||||||
Create a median filter. Picks the median pixel value in a window with the
|
|
||||||
given size.
|
|
||||||
|
|
||||||
:param size: The kernel size, in pixels.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "Median"
|
|
||||||
|
|
||||||
def __init__(self, size: int = 3) -> None:
|
|
||||||
self.size = size
|
|
||||||
self.rank = size * size // 2
|
|
||||||
|
|
||||||
|
|
||||||
class MinFilter(RankFilter):
|
|
||||||
"""
|
|
||||||
Create a min filter. Picks the lowest pixel value in a window with the
|
|
||||||
given size.
|
|
||||||
|
|
||||||
:param size: The kernel size, in pixels.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "Min"
|
|
||||||
|
|
||||||
def __init__(self, size: int = 3) -> None:
|
|
||||||
self.size = size
|
|
||||||
self.rank = 0
|
|
||||||
|
|
||||||
|
|
||||||
class MaxFilter(RankFilter):
|
|
||||||
"""
|
|
||||||
Create a max filter. Picks the largest pixel value in a window with the
|
|
||||||
given size.
|
|
||||||
|
|
||||||
:param size: The kernel size, in pixels.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "Max"
|
|
||||||
|
|
||||||
def __init__(self, size: int = 3) -> None:
|
|
||||||
self.size = size
|
|
||||||
self.rank = size * size - 1
|
|
||||||
|
|
||||||
|
|
||||||
class ModeFilter(Filter):
|
|
||||||
"""
|
|
||||||
Create a mode filter. Picks the most frequent pixel value in a box with the
|
|
||||||
given size. Pixel values that occur only once or twice are ignored; if no
|
|
||||||
pixel value occurs more than twice, the original pixel value is preserved.
|
|
||||||
|
|
||||||
:param size: The kernel size, in pixels.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "Mode"
|
|
||||||
|
|
||||||
def __init__(self, size: int = 3) -> None:
|
|
||||||
self.size = size
|
|
||||||
|
|
||||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
|
||||||
return image.modefilter(self.size)
|
|
||||||
|
|
||||||
|
|
||||||
class GaussianBlur(MultibandFilter):
|
|
||||||
"""Blurs the image with a sequence of extended box filters, which
|
|
||||||
approximates a Gaussian kernel. For details on accuracy see
|
|
||||||
<https://www.mia.uni-saarland.de/Publications/gwosdek-ssvm11.pdf>
|
|
||||||
|
|
||||||
:param radius: Standard deviation of the Gaussian kernel. Either a sequence of two
|
|
||||||
numbers for x and y, or a single number for both.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "GaussianBlur"
|
|
||||||
|
|
||||||
def __init__(self, radius: float | Sequence[float] = 2) -> None:
|
|
||||||
self.radius = radius
|
|
||||||
|
|
||||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
|
||||||
xy = self.radius
|
|
||||||
if isinstance(xy, (int, float)):
|
|
||||||
xy = (xy, xy)
|
|
||||||
if xy == (0, 0):
|
|
||||||
return image.copy()
|
|
||||||
return image.gaussian_blur(xy)
|
|
||||||
|
|
||||||
|
|
||||||
class BoxBlur(MultibandFilter):
|
|
||||||
"""Blurs the image by setting each pixel to the average value of the pixels
|
|
||||||
in a square box extending radius pixels in each direction.
|
|
||||||
Supports float radius of arbitrary size. Uses an optimized implementation
|
|
||||||
which runs in linear time relative to the size of the image
|
|
||||||
for any radius value.
|
|
||||||
|
|
||||||
:param radius: Size of the box in a direction. Either a sequence of two numbers for
|
|
||||||
x and y, or a single number for both.
|
|
||||||
|
|
||||||
Radius 0 does not blur, returns an identical image.
|
|
||||||
Radius 1 takes 1 pixel in each direction, i.e. 9 pixels in total.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "BoxBlur"
|
|
||||||
|
|
||||||
def __init__(self, radius: float | Sequence[float]) -> None:
|
|
||||||
xy = radius if isinstance(radius, (tuple, list)) else (radius, radius)
|
|
||||||
if xy[0] < 0 or xy[1] < 0:
|
|
||||||
msg = "radius must be >= 0"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.radius = radius
|
|
||||||
|
|
||||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
|
||||||
xy = self.radius
|
|
||||||
if isinstance(xy, (int, float)):
|
|
||||||
xy = (xy, xy)
|
|
||||||
if xy == (0, 0):
|
|
||||||
return image.copy()
|
|
||||||
return image.box_blur(xy)
|
|
||||||
|
|
||||||
|
|
||||||
class UnsharpMask(MultibandFilter):
|
|
||||||
"""Unsharp mask filter.
|
|
||||||
|
|
||||||
See Wikipedia's entry on `digital unsharp masking`_ for an explanation of
|
|
||||||
the parameters.
|
|
||||||
|
|
||||||
:param radius: Blur Radius
|
|
||||||
:param percent: Unsharp strength, in percent
|
|
||||||
:param threshold: Threshold controls the minimum brightness change that
|
|
||||||
will be sharpened
|
|
||||||
|
|
||||||
.. _digital unsharp masking: https://en.wikipedia.org/wiki/Unsharp_masking#Digital_unsharp_masking
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "UnsharpMask"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, radius: float = 2, percent: int = 150, threshold: int = 3
|
|
||||||
) -> None:
|
|
||||||
self.radius = radius
|
|
||||||
self.percent = percent
|
|
||||||
self.threshold = threshold
|
|
||||||
|
|
||||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
|
||||||
return image.unsharp_mask(self.radius, self.percent, self.threshold)
|
|
||||||
|
|
||||||
|
|
||||||
class BLUR(BuiltinFilter):
|
|
||||||
name = "Blur"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (5, 5), 16, 0, (
|
|
||||||
1, 1, 1, 1, 1,
|
|
||||||
1, 0, 0, 0, 1,
|
|
||||||
1, 0, 0, 0, 1,
|
|
||||||
1, 0, 0, 0, 1,
|
|
||||||
1, 1, 1, 1, 1,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class CONTOUR(BuiltinFilter):
|
|
||||||
name = "Contour"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (3, 3), 1, 255, (
|
|
||||||
-1, -1, -1,
|
|
||||||
-1, 8, -1,
|
|
||||||
-1, -1, -1,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class DETAIL(BuiltinFilter):
|
|
||||||
name = "Detail"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (3, 3), 6, 0, (
|
|
||||||
0, -1, 0,
|
|
||||||
-1, 10, -1,
|
|
||||||
0, -1, 0,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class EDGE_ENHANCE(BuiltinFilter):
|
|
||||||
name = "Edge-enhance"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (3, 3), 2, 0, (
|
|
||||||
-1, -1, -1,
|
|
||||||
-1, 10, -1,
|
|
||||||
-1, -1, -1,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class EDGE_ENHANCE_MORE(BuiltinFilter):
|
|
||||||
name = "Edge-enhance More"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (3, 3), 1, 0, (
|
|
||||||
-1, -1, -1,
|
|
||||||
-1, 9, -1,
|
|
||||||
-1, -1, -1,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class EMBOSS(BuiltinFilter):
|
|
||||||
name = "Emboss"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (3, 3), 1, 128, (
|
|
||||||
-1, 0, 0,
|
|
||||||
0, 1, 0,
|
|
||||||
0, 0, 0,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class FIND_EDGES(BuiltinFilter):
|
|
||||||
name = "Find Edges"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (3, 3), 1, 0, (
|
|
||||||
-1, -1, -1,
|
|
||||||
-1, 8, -1,
|
|
||||||
-1, -1, -1,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class SHARPEN(BuiltinFilter):
|
|
||||||
name = "Sharpen"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (3, 3), 16, 0, (
|
|
||||||
-2, -2, -2,
|
|
||||||
-2, 32, -2,
|
|
||||||
-2, -2, -2,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class SMOOTH(BuiltinFilter):
|
|
||||||
name = "Smooth"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (3, 3), 13, 0, (
|
|
||||||
1, 1, 1,
|
|
||||||
1, 5, 1,
|
|
||||||
1, 1, 1,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class SMOOTH_MORE(BuiltinFilter):
|
|
||||||
name = "Smooth More"
|
|
||||||
# fmt: off
|
|
||||||
filterargs = (5, 5), 100, 0, (
|
|
||||||
1, 1, 1, 1, 1,
|
|
||||||
1, 5, 5, 5, 1,
|
|
||||||
1, 5, 44, 5, 1,
|
|
||||||
1, 5, 5, 5, 1,
|
|
||||||
1, 1, 1, 1, 1,
|
|
||||||
)
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class Color3DLUT(MultibandFilter):
|
|
||||||
"""Three-dimensional color lookup table.
|
|
||||||
|
|
||||||
Transforms 3-channel pixels using the values of the channels as coordinates
|
|
||||||
in the 3D lookup table and interpolating the nearest elements.
|
|
||||||
|
|
||||||
This method allows you to apply almost any color transformation
|
|
||||||
in constant time by using pre-calculated decimated tables.
|
|
||||||
|
|
||||||
.. versionadded:: 5.2.0
|
|
||||||
|
|
||||||
:param size: Size of the table. One int or tuple of (int, int, int).
|
|
||||||
Minimal size in any dimension is 2, maximum is 65.
|
|
||||||
:param table: Flat lookup table. A list of ``channels * size**3``
|
|
||||||
float elements or a list of ``size**3`` channels-sized
|
|
||||||
tuples with floats. Channels are changed first,
|
|
||||||
then first dimension, then second, then third.
|
|
||||||
Value 0.0 corresponds lowest value of output, 1.0 highest.
|
|
||||||
:param channels: Number of channels in the table. Could be 3 or 4.
|
|
||||||
Default is 3.
|
|
||||||
:param target_mode: A mode for the result image. Should have not less
|
|
||||||
than ``channels`` channels. Default is ``None``,
|
|
||||||
which means that mode wouldn't be changed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
name = "Color 3D LUT"
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
size: int | tuple[int, int, int],
|
|
||||||
table: Sequence[float] | Sequence[Sequence[int]] | NumpyArray,
|
|
||||||
channels: int = 3,
|
|
||||||
target_mode: str | None = None,
|
|
||||||
**kwargs: bool,
|
|
||||||
) -> None:
|
|
||||||
if channels not in (3, 4):
|
|
||||||
msg = "Only 3 or 4 output channels are supported"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.size = size = self._check_size(size)
|
|
||||||
self.channels = channels
|
|
||||||
self.mode = target_mode
|
|
||||||
|
|
||||||
# Hidden flag `_copy_table=False` could be used to avoid extra copying
|
|
||||||
# of the table if the table is specially made for the constructor.
|
|
||||||
copy_table = kwargs.get("_copy_table", True)
|
|
||||||
items = size[0] * size[1] * size[2]
|
|
||||||
wrong_size = False
|
|
||||||
|
|
||||||
numpy: ModuleType | None = None
|
|
||||||
if hasattr(table, "shape"):
|
|
||||||
try:
|
|
||||||
import numpy
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if numpy and isinstance(table, numpy.ndarray):
|
|
||||||
numpy_table: NumpyArray = table
|
|
||||||
if copy_table:
|
|
||||||
numpy_table = numpy_table.copy()
|
|
||||||
|
|
||||||
if numpy_table.shape in [
|
|
||||||
(items * channels,),
|
|
||||||
(items, channels),
|
|
||||||
(size[2], size[1], size[0], channels),
|
|
||||||
]:
|
|
||||||
table = numpy_table.reshape(items * channels)
|
|
||||||
else:
|
|
||||||
wrong_size = True
|
|
||||||
|
|
||||||
else:
|
|
||||||
if copy_table:
|
|
||||||
table = list(table)
|
|
||||||
|
|
||||||
# Convert to a flat list
|
|
||||||
if table and isinstance(table[0], (list, tuple)):
|
|
||||||
raw_table = cast(Sequence[Sequence[int]], table)
|
|
||||||
flat_table: list[int] = []
|
|
||||||
for pixel in raw_table:
|
|
||||||
if len(pixel) != channels:
|
|
||||||
msg = (
|
|
||||||
"The elements of the table should "
|
|
||||||
f"have a length of {channels}."
|
|
||||||
)
|
|
||||||
raise ValueError(msg)
|
|
||||||
flat_table.extend(pixel)
|
|
||||||
table = flat_table
|
|
||||||
|
|
||||||
if wrong_size or len(table) != items * channels:
|
|
||||||
msg = (
|
|
||||||
"The table should have either channels * size**3 float items "
|
|
||||||
"or size**3 items of channels-sized tuples with floats. "
|
|
||||||
f"Table should be: {channels}x{size[0]}x{size[1]}x{size[2]}. "
|
|
||||||
f"Actual length: {len(table)}"
|
|
||||||
)
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.table = table
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _check_size(size: Any) -> tuple[int, int, int]:
|
|
||||||
try:
|
|
||||||
_, _, _ = size
|
|
||||||
except ValueError as e:
|
|
||||||
msg = "Size should be either an integer or a tuple of three integers."
|
|
||||||
raise ValueError(msg) from e
|
|
||||||
except TypeError:
|
|
||||||
size = (size, size, size)
|
|
||||||
size = tuple(int(x) for x in size)
|
|
||||||
for size_1d in size:
|
|
||||||
if not 2 <= size_1d <= 65:
|
|
||||||
msg = "Size should be in [2, 65] range."
|
|
||||||
raise ValueError(msg)
|
|
||||||
return size
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def generate(
|
|
||||||
cls,
|
|
||||||
size: int | tuple[int, int, int],
|
|
||||||
callback: Callable[[float, float, float], tuple[float, ...]],
|
|
||||||
channels: int = 3,
|
|
||||||
target_mode: str | None = None,
|
|
||||||
) -> Color3DLUT:
|
|
||||||
"""Generates new LUT using provided callback.
|
|
||||||
|
|
||||||
:param size: Size of the table. Passed to the constructor.
|
|
||||||
:param callback: Function with three parameters which correspond
|
|
||||||
three color channels. Will be called ``size**3``
|
|
||||||
times with values from 0.0 to 1.0 and should return
|
|
||||||
a tuple with ``channels`` elements.
|
|
||||||
:param channels: The number of channels which should return callback.
|
|
||||||
:param target_mode: Passed to the constructor of the resulting
|
|
||||||
lookup table.
|
|
||||||
"""
|
|
||||||
size_1d, size_2d, size_3d = cls._check_size(size)
|
|
||||||
if channels not in (3, 4):
|
|
||||||
msg = "Only 3 or 4 output channels are supported"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
table: list[float] = [0] * (size_1d * size_2d * size_3d * channels)
|
|
||||||
idx_out = 0
|
|
||||||
for b in range(size_3d):
|
|
||||||
for g in range(size_2d):
|
|
||||||
for r in range(size_1d):
|
|
||||||
table[idx_out : idx_out + channels] = callback(
|
|
||||||
r / (size_1d - 1), g / (size_2d - 1), b / (size_3d - 1)
|
|
||||||
)
|
|
||||||
idx_out += channels
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
(size_1d, size_2d, size_3d),
|
|
||||||
table,
|
|
||||||
channels=channels,
|
|
||||||
target_mode=target_mode,
|
|
||||||
_copy_table=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def transform(
|
|
||||||
self,
|
|
||||||
callback: Callable[..., tuple[float, ...]],
|
|
||||||
with_normals: bool = False,
|
|
||||||
channels: int | None = None,
|
|
||||||
target_mode: str | None = None,
|
|
||||||
) -> Color3DLUT:
|
|
||||||
"""Transforms the table values using provided callback and returns
|
|
||||||
a new LUT with altered values.
|
|
||||||
|
|
||||||
:param callback: A function which takes old lookup table values
|
|
||||||
and returns a new set of values. The number
|
|
||||||
of arguments which function should take is
|
|
||||||
``self.channels`` or ``3 + self.channels``
|
|
||||||
if ``with_normals`` flag is set.
|
|
||||||
Should return a tuple of ``self.channels`` or
|
|
||||||
``channels`` elements if it is set.
|
|
||||||
:param with_normals: If true, ``callback`` will be called with
|
|
||||||
coordinates in the color cube as the first
|
|
||||||
three arguments. Otherwise, ``callback``
|
|
||||||
will be called only with actual color values.
|
|
||||||
:param channels: The number of channels in the resulting lookup table.
|
|
||||||
:param target_mode: Passed to the constructor of the resulting
|
|
||||||
lookup table.
|
|
||||||
"""
|
|
||||||
if channels not in (None, 3, 4):
|
|
||||||
msg = "Only 3 or 4 output channels are supported"
|
|
||||||
raise ValueError(msg)
|
|
||||||
ch_in = self.channels
|
|
||||||
ch_out = channels or ch_in
|
|
||||||
size_1d, size_2d, size_3d = self.size
|
|
||||||
|
|
||||||
table: list[float] = [0] * (size_1d * size_2d * size_3d * ch_out)
|
|
||||||
idx_in = 0
|
|
||||||
idx_out = 0
|
|
||||||
for b in range(size_3d):
|
|
||||||
for g in range(size_2d):
|
|
||||||
for r in range(size_1d):
|
|
||||||
values = self.table[idx_in : idx_in + ch_in]
|
|
||||||
if with_normals:
|
|
||||||
values = callback(
|
|
||||||
r / (size_1d - 1),
|
|
||||||
g / (size_2d - 1),
|
|
||||||
b / (size_3d - 1),
|
|
||||||
*values,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
values = callback(*values)
|
|
||||||
table[idx_out : idx_out + ch_out] = values
|
|
||||||
idx_in += ch_in
|
|
||||||
idx_out += ch_out
|
|
||||||
|
|
||||||
return type(self)(
|
|
||||||
self.size,
|
|
||||||
table,
|
|
||||||
channels=ch_out,
|
|
||||||
target_mode=target_mode or self.mode,
|
|
||||||
_copy_table=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
r = [
|
|
||||||
f"{self.__class__.__name__} from {self.table.__class__.__name__}",
|
|
||||||
"size={:d}x{:d}x{:d}".format(*self.size),
|
|
||||||
f"channels={self.channels:d}",
|
|
||||||
]
|
|
||||||
if self.mode:
|
|
||||||
r.append(f"target_mode={self.mode}")
|
|
||||||
return "<{}>".format(" ".join(r))
|
|
||||||
|
|
||||||
def filter(self, image: _imaging.ImagingCore) -> _imaging.ImagingCore:
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
return image.color_lut_3d(
|
|
||||||
self.mode or image.mode,
|
|
||||||
Image.Resampling.BILINEAR,
|
|
||||||
self.channels,
|
|
||||||
self.size,
|
|
||||||
self.table,
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,231 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# screen grabber
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2001-04-26 fl created
|
|
||||||
# 2001-09-17 fl use builtin driver, if present
|
|
||||||
# 2002-11-19 fl added grabclipboard support
|
|
||||||
#
|
|
||||||
# Copyright (c) 2001-2002 by Secret Labs AB
|
|
||||||
# Copyright (c) 2001-2002 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from . import ImageWin
|
|
||||||
|
|
||||||
|
|
||||||
def grab(
|
|
||||||
bbox: tuple[int, int, int, int] | None = None,
|
|
||||||
include_layered_windows: bool = False,
|
|
||||||
all_screens: bool = False,
|
|
||||||
xdisplay: str | None = None,
|
|
||||||
window: int | ImageWin.HWND | None = None,
|
|
||||||
) -> Image.Image:
|
|
||||||
im: Image.Image
|
|
||||||
if xdisplay is None:
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
fh, filepath = tempfile.mkstemp(".png")
|
|
||||||
os.close(fh)
|
|
||||||
args = ["screencapture"]
|
|
||||||
if window is not None:
|
|
||||||
args += ["-l", str(window)]
|
|
||||||
elif bbox:
|
|
||||||
left, top, right, bottom = bbox
|
|
||||||
args += ["-R", f"{left},{top},{right-left},{bottom-top}"]
|
|
||||||
args += ["-x", filepath]
|
|
||||||
retcode = subprocess.call(args)
|
|
||||||
if retcode:
|
|
||||||
raise subprocess.CalledProcessError(retcode, args)
|
|
||||||
im = Image.open(filepath)
|
|
||||||
im.load()
|
|
||||||
os.unlink(filepath)
|
|
||||||
if bbox:
|
|
||||||
if window is not None:
|
|
||||||
# Determine if the window was in Retina mode or not
|
|
||||||
# by capturing it without the shadow,
|
|
||||||
# and checking how different the width is
|
|
||||||
fh, filepath = tempfile.mkstemp(".png")
|
|
||||||
os.close(fh)
|
|
||||||
args = ["screencapture", "-l", str(window), "-o", "-x", filepath]
|
|
||||||
retcode = subprocess.call(args)
|
|
||||||
if retcode:
|
|
||||||
raise subprocess.CalledProcessError(retcode, args)
|
|
||||||
with Image.open(filepath) as im_no_shadow:
|
|
||||||
retina = im.width - im_no_shadow.width > 100
|
|
||||||
os.unlink(filepath)
|
|
||||||
|
|
||||||
# Since screencapture's -R does not work with -l,
|
|
||||||
# crop the image manually
|
|
||||||
if retina:
|
|
||||||
left, top, right, bottom = bbox
|
|
||||||
im_cropped = im.resize(
|
|
||||||
(right - left, bottom - top),
|
|
||||||
box=tuple(coord * 2 for coord in bbox),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
im_cropped = im.crop(bbox)
|
|
||||||
im.close()
|
|
||||||
return im_cropped
|
|
||||||
else:
|
|
||||||
im_resized = im.resize((right - left, bottom - top))
|
|
||||||
im.close()
|
|
||||||
return im_resized
|
|
||||||
return im
|
|
||||||
elif sys.platform == "win32":
|
|
||||||
if window is not None:
|
|
||||||
all_screens = -1
|
|
||||||
offset, size, data = Image.core.grabscreen_win32(
|
|
||||||
include_layered_windows,
|
|
||||||
all_screens,
|
|
||||||
int(window) if window is not None else 0,
|
|
||||||
)
|
|
||||||
im = Image.frombytes(
|
|
||||||
"RGB",
|
|
||||||
size,
|
|
||||||
data,
|
|
||||||
# RGB, 32-bit line padding, origin lower left corner
|
|
||||||
"raw",
|
|
||||||
"BGR",
|
|
||||||
(size[0] * 3 + 3) & -4,
|
|
||||||
-1,
|
|
||||||
)
|
|
||||||
if bbox:
|
|
||||||
x0, y0 = offset
|
|
||||||
left, top, right, bottom = bbox
|
|
||||||
im = im.crop((left - x0, top - y0, right - x0, bottom - y0))
|
|
||||||
return im
|
|
||||||
# Cast to Optional[str] needed for Windows and macOS.
|
|
||||||
display_name: str | None = xdisplay
|
|
||||||
try:
|
|
||||||
if not Image.core.HAVE_XCB:
|
|
||||||
msg = "Pillow was built without XCB support"
|
|
||||||
raise OSError(msg)
|
|
||||||
size, data = Image.core.grabscreen_x11(display_name)
|
|
||||||
except OSError:
|
|
||||||
if display_name is None and sys.platform not in ("darwin", "win32"):
|
|
||||||
if shutil.which("gnome-screenshot"):
|
|
||||||
args = ["gnome-screenshot", "-f"]
|
|
||||||
elif shutil.which("grim"):
|
|
||||||
args = ["grim"]
|
|
||||||
elif shutil.which("spectacle"):
|
|
||||||
args = ["spectacle", "-n", "-b", "-f", "-o"]
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
fh, filepath = tempfile.mkstemp(".png")
|
|
||||||
os.close(fh)
|
|
||||||
args.append(filepath)
|
|
||||||
retcode = subprocess.call(args)
|
|
||||||
if retcode:
|
|
||||||
raise subprocess.CalledProcessError(retcode, args)
|
|
||||||
im = Image.open(filepath)
|
|
||||||
im.load()
|
|
||||||
os.unlink(filepath)
|
|
||||||
if bbox:
|
|
||||||
im_cropped = im.crop(bbox)
|
|
||||||
im.close()
|
|
||||||
return im_cropped
|
|
||||||
return im
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
im = Image.frombytes("RGB", size, data, "raw", "BGRX", size[0] * 4, 1)
|
|
||||||
if bbox:
|
|
||||||
im = im.crop(bbox)
|
|
||||||
return im
|
|
||||||
|
|
||||||
|
|
||||||
def grabclipboard() -> Image.Image | list[str] | None:
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
p = subprocess.run(
|
|
||||||
["osascript", "-e", "get the clipboard as «class PNGf»"],
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
if p.returncode != 0:
|
|
||||||
return None
|
|
||||||
|
|
||||||
import binascii
|
|
||||||
|
|
||||||
data = io.BytesIO(binascii.unhexlify(p.stdout[11:-3]))
|
|
||||||
return Image.open(data)
|
|
||||||
elif sys.platform == "win32":
|
|
||||||
fmt, data = Image.core.grabclipboard_win32()
|
|
||||||
if fmt == "file": # CF_HDROP
|
|
||||||
import struct
|
|
||||||
|
|
||||||
o = struct.unpack_from("I", data)[0]
|
|
||||||
if data[16] == 0:
|
|
||||||
files = data[o:].decode("mbcs").split("\0")
|
|
||||||
else:
|
|
||||||
files = data[o:].decode("utf-16le").split("\0")
|
|
||||||
return files[: files.index("")]
|
|
||||||
if isinstance(data, bytes):
|
|
||||||
data = io.BytesIO(data)
|
|
||||||
if fmt == "png":
|
|
||||||
from . import PngImagePlugin
|
|
||||||
|
|
||||||
return PngImagePlugin.PngImageFile(data)
|
|
||||||
elif fmt == "DIB":
|
|
||||||
from . import BmpImagePlugin
|
|
||||||
|
|
||||||
return BmpImagePlugin.DibImageFile(data)
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
if os.getenv("WAYLAND_DISPLAY"):
|
|
||||||
session_type = "wayland"
|
|
||||||
elif os.getenv("DISPLAY"):
|
|
||||||
session_type = "x11"
|
|
||||||
else: # Session type check failed
|
|
||||||
session_type = None
|
|
||||||
|
|
||||||
if shutil.which("wl-paste") and session_type in ("wayland", None):
|
|
||||||
args = ["wl-paste", "-t", "image"]
|
|
||||||
elif shutil.which("xclip") and session_type in ("x11", None):
|
|
||||||
args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]
|
|
||||||
else:
|
|
||||||
msg = "wl-paste or xclip is required for ImageGrab.grabclipboard() on Linux"
|
|
||||||
raise NotImplementedError(msg)
|
|
||||||
|
|
||||||
p = subprocess.run(args, capture_output=True)
|
|
||||||
if p.returncode != 0:
|
|
||||||
err = p.stderr
|
|
||||||
for silent_error in [
|
|
||||||
# wl-paste, when the clipboard is empty
|
|
||||||
b"Nothing is copied",
|
|
||||||
# Ubuntu/Debian wl-paste, when the clipboard is empty
|
|
||||||
b"No selection",
|
|
||||||
# Ubuntu/Debian wl-paste, when an image isn't available
|
|
||||||
b"No suitable type of content copied",
|
|
||||||
# wl-paste or Ubuntu/Debian xclip, when an image isn't available
|
|
||||||
b" not available",
|
|
||||||
# xclip, when an image isn't available
|
|
||||||
b"cannot convert ",
|
|
||||||
# xclip, when the clipboard isn't initialized
|
|
||||||
b"xclip: Error: There is no owner for the ",
|
|
||||||
]:
|
|
||||||
if silent_error in err:
|
|
||||||
return None
|
|
||||||
msg = f"{args[0]} error"
|
|
||||||
if err:
|
|
||||||
msg += f": {err.strip().decode()}"
|
|
||||||
raise ChildProcessError(msg)
|
|
||||||
|
|
||||||
data = io.BytesIO(p.stdout)
|
|
||||||
im = Image.open(data)
|
|
||||||
im.load()
|
|
||||||
return im
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# a simple math add-on for the Python Imaging Library
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1999-02-15 fl Original PIL Plus release
|
|
||||||
# 2005-05-05 fl Simplified and cleaned up for PIL 1.1.6
|
|
||||||
# 2005-09-12 fl Fixed int() and float() for Python 2.4.1
|
|
||||||
#
|
|
||||||
# Copyright (c) 1999-2005 by Secret Labs AB
|
|
||||||
# Copyright (c) 2005 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import builtins
|
|
||||||
|
|
||||||
from . import Image, _imagingmath
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
from types import CodeType
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class _Operand:
|
|
||||||
"""Wraps an image operand, providing standard operators"""
|
|
||||||
|
|
||||||
def __init__(self, im: Image.Image):
|
|
||||||
self.im = im
|
|
||||||
|
|
||||||
def __fixup(self, im1: _Operand | float) -> Image.Image:
|
|
||||||
# convert image to suitable mode
|
|
||||||
if isinstance(im1, _Operand):
|
|
||||||
# argument was an image.
|
|
||||||
if im1.im.mode in ("1", "L"):
|
|
||||||
return im1.im.convert("I")
|
|
||||||
elif im1.im.mode in ("I", "F"):
|
|
||||||
return im1.im
|
|
||||||
else:
|
|
||||||
msg = f"unsupported mode: {im1.im.mode}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
else:
|
|
||||||
# argument was a constant
|
|
||||||
if isinstance(im1, (int, float)) and self.im.mode in ("1", "L", "I"):
|
|
||||||
return Image.new("I", self.im.size, im1)
|
|
||||||
else:
|
|
||||||
return Image.new("F", self.im.size, im1)
|
|
||||||
|
|
||||||
def apply(
|
|
||||||
self,
|
|
||||||
op: str,
|
|
||||||
im1: _Operand | float,
|
|
||||||
im2: _Operand | float | None = None,
|
|
||||||
mode: str | None = None,
|
|
||||||
) -> _Operand:
|
|
||||||
im_1 = self.__fixup(im1)
|
|
||||||
if im2 is None:
|
|
||||||
# unary operation
|
|
||||||
out = Image.new(mode or im_1.mode, im_1.size, None)
|
|
||||||
try:
|
|
||||||
op = getattr(_imagingmath, f"{op}_{im_1.mode}")
|
|
||||||
except AttributeError as e:
|
|
||||||
msg = f"bad operand type for '{op}'"
|
|
||||||
raise TypeError(msg) from e
|
|
||||||
_imagingmath.unop(op, out.getim(), im_1.getim())
|
|
||||||
else:
|
|
||||||
# binary operation
|
|
||||||
im_2 = self.__fixup(im2)
|
|
||||||
if im_1.mode != im_2.mode:
|
|
||||||
# convert both arguments to floating point
|
|
||||||
if im_1.mode != "F":
|
|
||||||
im_1 = im_1.convert("F")
|
|
||||||
if im_2.mode != "F":
|
|
||||||
im_2 = im_2.convert("F")
|
|
||||||
if im_1.size != im_2.size:
|
|
||||||
# crop both arguments to a common size
|
|
||||||
size = (
|
|
||||||
min(im_1.size[0], im_2.size[0]),
|
|
||||||
min(im_1.size[1], im_2.size[1]),
|
|
||||||
)
|
|
||||||
if im_1.size != size:
|
|
||||||
im_1 = im_1.crop((0, 0) + size)
|
|
||||||
if im_2.size != size:
|
|
||||||
im_2 = im_2.crop((0, 0) + size)
|
|
||||||
out = Image.new(mode or im_1.mode, im_1.size, None)
|
|
||||||
try:
|
|
||||||
op = getattr(_imagingmath, f"{op}_{im_1.mode}")
|
|
||||||
except AttributeError as e:
|
|
||||||
msg = f"bad operand type for '{op}'"
|
|
||||||
raise TypeError(msg) from e
|
|
||||||
_imagingmath.binop(op, out.getim(), im_1.getim(), im_2.getim())
|
|
||||||
return _Operand(out)
|
|
||||||
|
|
||||||
# unary operators
|
|
||||||
def __bool__(self) -> bool:
|
|
||||||
# an image is "true" if it contains at least one non-zero pixel
|
|
||||||
return self.im.getbbox() is not None
|
|
||||||
|
|
||||||
def __abs__(self) -> _Operand:
|
|
||||||
return self.apply("abs", self)
|
|
||||||
|
|
||||||
def __pos__(self) -> _Operand:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __neg__(self) -> _Operand:
|
|
||||||
return self.apply("neg", self)
|
|
||||||
|
|
||||||
# binary operators
|
|
||||||
def __add__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("add", self, other)
|
|
||||||
|
|
||||||
def __radd__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("add", other, self)
|
|
||||||
|
|
||||||
def __sub__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("sub", self, other)
|
|
||||||
|
|
||||||
def __rsub__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("sub", other, self)
|
|
||||||
|
|
||||||
def __mul__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("mul", self, other)
|
|
||||||
|
|
||||||
def __rmul__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("mul", other, self)
|
|
||||||
|
|
||||||
def __truediv__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("div", self, other)
|
|
||||||
|
|
||||||
def __rtruediv__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("div", other, self)
|
|
||||||
|
|
||||||
def __mod__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("mod", self, other)
|
|
||||||
|
|
||||||
def __rmod__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("mod", other, self)
|
|
||||||
|
|
||||||
def __pow__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("pow", self, other)
|
|
||||||
|
|
||||||
def __rpow__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("pow", other, self)
|
|
||||||
|
|
||||||
# bitwise
|
|
||||||
def __invert__(self) -> _Operand:
|
|
||||||
return self.apply("invert", self)
|
|
||||||
|
|
||||||
def __and__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("and", self, other)
|
|
||||||
|
|
||||||
def __rand__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("and", other, self)
|
|
||||||
|
|
||||||
def __or__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("or", self, other)
|
|
||||||
|
|
||||||
def __ror__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("or", other, self)
|
|
||||||
|
|
||||||
def __xor__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("xor", self, other)
|
|
||||||
|
|
||||||
def __rxor__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("xor", other, self)
|
|
||||||
|
|
||||||
def __lshift__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("lshift", self, other)
|
|
||||||
|
|
||||||
def __rshift__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("rshift", self, other)
|
|
||||||
|
|
||||||
# logical
|
|
||||||
def __eq__(self, other: _Operand | float) -> _Operand: # type: ignore[override]
|
|
||||||
return self.apply("eq", self, other)
|
|
||||||
|
|
||||||
def __ne__(self, other: _Operand | float) -> _Operand: # type: ignore[override]
|
|
||||||
return self.apply("ne", self, other)
|
|
||||||
|
|
||||||
def __lt__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("lt", self, other)
|
|
||||||
|
|
||||||
def __le__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("le", self, other)
|
|
||||||
|
|
||||||
def __gt__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("gt", self, other)
|
|
||||||
|
|
||||||
def __ge__(self, other: _Operand | float) -> _Operand:
|
|
||||||
return self.apply("ge", self, other)
|
|
||||||
|
|
||||||
|
|
||||||
# conversions
|
|
||||||
def imagemath_int(self: _Operand) -> _Operand:
|
|
||||||
return _Operand(self.im.convert("I"))
|
|
||||||
|
|
||||||
|
|
||||||
def imagemath_float(self: _Operand) -> _Operand:
|
|
||||||
return _Operand(self.im.convert("F"))
|
|
||||||
|
|
||||||
|
|
||||||
# logical
|
|
||||||
def imagemath_equal(self: _Operand, other: _Operand | float | None) -> _Operand:
|
|
||||||
return self.apply("eq", self, other, mode="I")
|
|
||||||
|
|
||||||
|
|
||||||
def imagemath_notequal(self: _Operand, other: _Operand | float | None) -> _Operand:
|
|
||||||
return self.apply("ne", self, other, mode="I")
|
|
||||||
|
|
||||||
|
|
||||||
def imagemath_min(self: _Operand, other: _Operand | float | None) -> _Operand:
|
|
||||||
return self.apply("min", self, other)
|
|
||||||
|
|
||||||
|
|
||||||
def imagemath_max(self: _Operand, other: _Operand | float | None) -> _Operand:
|
|
||||||
return self.apply("max", self, other)
|
|
||||||
|
|
||||||
|
|
||||||
def imagemath_convert(self: _Operand, mode: str) -> _Operand:
|
|
||||||
return _Operand(self.im.convert(mode))
|
|
||||||
|
|
||||||
|
|
||||||
ops = {
|
|
||||||
"int": imagemath_int,
|
|
||||||
"float": imagemath_float,
|
|
||||||
"equal": imagemath_equal,
|
|
||||||
"notequal": imagemath_notequal,
|
|
||||||
"min": imagemath_min,
|
|
||||||
"max": imagemath_max,
|
|
||||||
"convert": imagemath_convert,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def lambda_eval(expression: Callable[[dict[str, Any]], Any], **kw: Any) -> Any:
|
|
||||||
"""
|
|
||||||
Returns the result of an image function.
|
|
||||||
|
|
||||||
:py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band
|
|
||||||
images, use the :py:meth:`~PIL.Image.Image.split` method or
|
|
||||||
:py:func:`~PIL.Image.merge` function.
|
|
||||||
|
|
||||||
:param expression: A function that receives a dictionary.
|
|
||||||
:param **kw: Values to add to the function's dictionary.
|
|
||||||
:return: The expression result. This is usually an image object, but can
|
|
||||||
also be an integer, a floating point value, or a pixel tuple,
|
|
||||||
depending on the expression.
|
|
||||||
"""
|
|
||||||
|
|
||||||
args: dict[str, Any] = ops.copy()
|
|
||||||
args.update(kw)
|
|
||||||
for k, v in args.items():
|
|
||||||
if isinstance(v, Image.Image):
|
|
||||||
args[k] = _Operand(v)
|
|
||||||
|
|
||||||
out = expression(args)
|
|
||||||
try:
|
|
||||||
return out.im
|
|
||||||
except AttributeError:
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def unsafe_eval(expression: str, **kw: Any) -> Any:
|
|
||||||
"""
|
|
||||||
Evaluates an image expression. This uses Python's ``eval()`` function to process
|
|
||||||
the expression string, and carries the security risks of doing so. It is not
|
|
||||||
recommended to process expressions without considering this.
|
|
||||||
:py:meth:`~lambda_eval` is a more secure alternative.
|
|
||||||
|
|
||||||
:py:mod:`~PIL.ImageMath` only supports single-layer images. To process multi-band
|
|
||||||
images, use the :py:meth:`~PIL.Image.Image.split` method or
|
|
||||||
:py:func:`~PIL.Image.merge` function.
|
|
||||||
|
|
||||||
:param expression: A string containing a Python-style expression.
|
|
||||||
:param **kw: Values to add to the evaluation context.
|
|
||||||
:return: The evaluated expression. This is usually an image object, but can
|
|
||||||
also be an integer, a floating point value, or a pixel tuple,
|
|
||||||
depending on the expression.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# build execution namespace
|
|
||||||
args: dict[str, Any] = ops.copy()
|
|
||||||
for k in kw:
|
|
||||||
if "__" in k or hasattr(builtins, k):
|
|
||||||
msg = f"'{k}' not allowed"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
args.update(kw)
|
|
||||||
for k, v in args.items():
|
|
||||||
if isinstance(v, Image.Image):
|
|
||||||
args[k] = _Operand(v)
|
|
||||||
|
|
||||||
compiled_code = compile(expression, "<string>", "eval")
|
|
||||||
|
|
||||||
def scan(code: CodeType) -> None:
|
|
||||||
for const in code.co_consts:
|
|
||||||
if type(const) is type(compiled_code):
|
|
||||||
scan(const)
|
|
||||||
|
|
||||||
for name in code.co_names:
|
|
||||||
if name not in args and name != "abs":
|
|
||||||
msg = f"'{name}' not allowed"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
scan(compiled_code)
|
|
||||||
out = builtins.eval(expression, {"__builtins": {"abs": abs}}, args)
|
|
||||||
try:
|
|
||||||
return out.im
|
|
||||||
except AttributeError:
|
|
||||||
return out
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# standard mode descriptors
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2006-03-20 fl Added
|
|
||||||
#
|
|
||||||
# Copyright (c) 2006 by Secret Labs AB.
|
|
||||||
# Copyright (c) 2006 by Fredrik Lundh.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from functools import lru_cache
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
|
|
||||||
class ModeDescriptor(NamedTuple):
|
|
||||||
"""Wrapper for mode strings."""
|
|
||||||
|
|
||||||
mode: str
|
|
||||||
bands: tuple[str, ...]
|
|
||||||
basemode: str
|
|
||||||
basetype: str
|
|
||||||
typestr: str
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.mode
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
|
||||||
def getmode(mode: str) -> ModeDescriptor:
|
|
||||||
"""Gets a mode descriptor for the given mode."""
|
|
||||||
endian = "<" if sys.byteorder == "little" else ">"
|
|
||||||
|
|
||||||
modes = {
|
|
||||||
# core modes
|
|
||||||
# Bits need to be extended to bytes
|
|
||||||
"1": ("L", "L", ("1",), "|b1"),
|
|
||||||
"L": ("L", "L", ("L",), "|u1"),
|
|
||||||
"I": ("L", "I", ("I",), f"{endian}i4"),
|
|
||||||
"F": ("L", "F", ("F",), f"{endian}f4"),
|
|
||||||
"P": ("P", "L", ("P",), "|u1"),
|
|
||||||
"RGB": ("RGB", "L", ("R", "G", "B"), "|u1"),
|
|
||||||
"RGBX": ("RGB", "L", ("R", "G", "B", "X"), "|u1"),
|
|
||||||
"RGBA": ("RGB", "L", ("R", "G", "B", "A"), "|u1"),
|
|
||||||
"CMYK": ("RGB", "L", ("C", "M", "Y", "K"), "|u1"),
|
|
||||||
"YCbCr": ("RGB", "L", ("Y", "Cb", "Cr"), "|u1"),
|
|
||||||
# UNDONE - unsigned |u1i1i1
|
|
||||||
"LAB": ("RGB", "L", ("L", "A", "B"), "|u1"),
|
|
||||||
"HSV": ("RGB", "L", ("H", "S", "V"), "|u1"),
|
|
||||||
# extra experimental modes
|
|
||||||
"RGBa": ("RGB", "L", ("R", "G", "B", "a"), "|u1"),
|
|
||||||
"LA": ("L", "L", ("L", "A"), "|u1"),
|
|
||||||
"La": ("L", "L", ("L", "a"), "|u1"),
|
|
||||||
"PA": ("RGB", "L", ("P", "A"), "|u1"),
|
|
||||||
}
|
|
||||||
if mode in modes:
|
|
||||||
base_mode, base_type, bands, type_str = modes[mode]
|
|
||||||
return ModeDescriptor(mode, bands, base_mode, base_type, type_str)
|
|
||||||
|
|
||||||
mapping_modes = {
|
|
||||||
# I;16 == I;16L, and I;32 == I;32L
|
|
||||||
"I;16": "<u2",
|
|
||||||
"I;16S": "<i2",
|
|
||||||
"I;16L": "<u2",
|
|
||||||
"I;16LS": "<i2",
|
|
||||||
"I;16B": ">u2",
|
|
||||||
"I;16BS": ">i2",
|
|
||||||
"I;16N": f"{endian}u2",
|
|
||||||
"I;16NS": f"{endian}i2",
|
|
||||||
"I;32": "<u4",
|
|
||||||
"I;32B": ">u4",
|
|
||||||
"I;32L": "<u4",
|
|
||||||
"I;32S": "<i4",
|
|
||||||
"I;32BS": ">i4",
|
|
||||||
"I;32LS": "<i4",
|
|
||||||
}
|
|
||||||
|
|
||||||
type_str = mapping_modes[mode]
|
|
||||||
return ModeDescriptor(mode, ("I",), "L", "L", type_str)
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
# A binary morphology add-on for the Python Imaging Library
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2014-06-04 Initial version.
|
|
||||||
#
|
|
||||||
# Copyright (c) 2014 Dov Grobgeld <dov.grobgeld@gmail.com>
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from . import Image, _imagingmorph
|
|
||||||
|
|
||||||
LUT_SIZE = 1 << 9
|
|
||||||
|
|
||||||
# fmt: off
|
|
||||||
ROTATION_MATRIX = [
|
|
||||||
6, 3, 0,
|
|
||||||
7, 4, 1,
|
|
||||||
8, 5, 2,
|
|
||||||
]
|
|
||||||
MIRROR_MATRIX = [
|
|
||||||
2, 1, 0,
|
|
||||||
5, 4, 3,
|
|
||||||
8, 7, 6,
|
|
||||||
]
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
class LutBuilder:
|
|
||||||
"""A class for building a MorphLut from a descriptive language
|
|
||||||
|
|
||||||
The input patterns is a list of a strings sequences like these::
|
|
||||||
|
|
||||||
4:(...
|
|
||||||
.1.
|
|
||||||
111)->1
|
|
||||||
|
|
||||||
(whitespaces including linebreaks are ignored). The option 4
|
|
||||||
describes a series of symmetry operations (in this case a
|
|
||||||
4-rotation), the pattern is described by:
|
|
||||||
|
|
||||||
- . or X - Ignore
|
|
||||||
- 1 - Pixel is on
|
|
||||||
- 0 - Pixel is off
|
|
||||||
|
|
||||||
The result of the operation is described after "->" string.
|
|
||||||
|
|
||||||
The default is to return the current pixel value, which is
|
|
||||||
returned if no other match is found.
|
|
||||||
|
|
||||||
Operations:
|
|
||||||
|
|
||||||
- 4 - 4 way rotation
|
|
||||||
- N - Negate
|
|
||||||
- 1 - Dummy op for no other operation (an op must always be given)
|
|
||||||
- M - Mirroring
|
|
||||||
|
|
||||||
Example::
|
|
||||||
|
|
||||||
lb = LutBuilder(patterns = ["4:(... .1. 111)->1"])
|
|
||||||
lut = lb.build_lut()
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, patterns: list[str] | None = None, op_name: str | None = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
:param patterns: A list of input patterns, or None.
|
|
||||||
:param op_name: The name of a known pattern. One of "corner", "dilation4",
|
|
||||||
"dilation8", "erosion4", "erosion8" or "edge".
|
|
||||||
:exception Exception: If the op_name is not recognized.
|
|
||||||
"""
|
|
||||||
self.lut: bytearray | None = None
|
|
||||||
if op_name is not None:
|
|
||||||
known_patterns = {
|
|
||||||
"corner": ["1:(... ... ...)->0", "4:(00. 01. ...)->1"],
|
|
||||||
"dilation4": ["4:(... .0. .1.)->1"],
|
|
||||||
"dilation8": ["4:(... .0. .1.)->1", "4:(... .0. ..1)->1"],
|
|
||||||
"erosion4": ["4:(... .1. .0.)->0"],
|
|
||||||
"erosion8": ["4:(... .1. .0.)->0", "4:(... .1. ..0)->0"],
|
|
||||||
"edge": [
|
|
||||||
"1:(... ... ...)->0",
|
|
||||||
"4:(.0. .1. ...)->1",
|
|
||||||
"4:(01. .1. ...)->1",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
if op_name not in known_patterns:
|
|
||||||
msg = f"Unknown pattern {op_name}!"
|
|
||||||
raise Exception(msg)
|
|
||||||
|
|
||||||
self.patterns = known_patterns[op_name]
|
|
||||||
elif patterns is not None:
|
|
||||||
self.patterns = patterns
|
|
||||||
else:
|
|
||||||
self.patterns = []
|
|
||||||
|
|
||||||
def add_patterns(self, patterns: list[str]) -> None:
|
|
||||||
"""
|
|
||||||
Append to list of patterns.
|
|
||||||
|
|
||||||
:param patterns: Additional patterns.
|
|
||||||
"""
|
|
||||||
self.patterns += patterns
|
|
||||||
|
|
||||||
def build_default_lut(self) -> bytearray:
|
|
||||||
"""
|
|
||||||
Set the current LUT, and return it.
|
|
||||||
|
|
||||||
This is the default LUT that patterns will be applied against when building.
|
|
||||||
"""
|
|
||||||
symbols = [0, 1]
|
|
||||||
m = 1 << 4 # pos of current pixel
|
|
||||||
self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE))
|
|
||||||
return self.lut
|
|
||||||
|
|
||||||
def get_lut(self) -> bytearray | None:
|
|
||||||
"""
|
|
||||||
Returns the current LUT
|
|
||||||
"""
|
|
||||||
return self.lut
|
|
||||||
|
|
||||||
def _string_permute(self, pattern: str, permutation: list[int]) -> str:
|
|
||||||
"""Takes a pattern and a permutation and returns the
|
|
||||||
string permuted according to the permutation list.
|
|
||||||
"""
|
|
||||||
assert len(permutation) == 9
|
|
||||||
return "".join(pattern[p] for p in permutation)
|
|
||||||
|
|
||||||
def _pattern_permute(
|
|
||||||
self, basic_pattern: str, options: str, basic_result: int
|
|
||||||
) -> list[tuple[str, int]]:
|
|
||||||
"""Takes a basic pattern and its result and clones
|
|
||||||
the pattern according to the modifications described in the $options
|
|
||||||
parameter. It returns a list of all cloned patterns."""
|
|
||||||
patterns = [(basic_pattern, basic_result)]
|
|
||||||
|
|
||||||
# rotations
|
|
||||||
if "4" in options:
|
|
||||||
res = patterns[-1][1]
|
|
||||||
for i in range(4):
|
|
||||||
patterns.append(
|
|
||||||
(self._string_permute(patterns[-1][0], ROTATION_MATRIX), res)
|
|
||||||
)
|
|
||||||
# mirror
|
|
||||||
if "M" in options:
|
|
||||||
n = len(patterns)
|
|
||||||
for pattern, res in patterns[:n]:
|
|
||||||
patterns.append((self._string_permute(pattern, MIRROR_MATRIX), res))
|
|
||||||
|
|
||||||
# negate
|
|
||||||
if "N" in options:
|
|
||||||
n = len(patterns)
|
|
||||||
for pattern, res in patterns[:n]:
|
|
||||||
# Swap 0 and 1
|
|
||||||
pattern = pattern.replace("0", "Z").replace("1", "0").replace("Z", "1")
|
|
||||||
res = 1 - int(res)
|
|
||||||
patterns.append((pattern, res))
|
|
||||||
|
|
||||||
return patterns
|
|
||||||
|
|
||||||
def build_lut(self) -> bytearray:
|
|
||||||
"""Compile all patterns into a morphology LUT, and return it.
|
|
||||||
|
|
||||||
This is the data to be passed into MorphOp."""
|
|
||||||
self.build_default_lut()
|
|
||||||
assert self.lut is not None
|
|
||||||
patterns = []
|
|
||||||
|
|
||||||
# Parse and create symmetries of the patterns strings
|
|
||||||
for p in self.patterns:
|
|
||||||
m = re.search(r"(\w):?\s*\((.+?)\)\s*->\s*(\d)", p.replace("\n", ""))
|
|
||||||
if not m:
|
|
||||||
msg = 'Syntax error in pattern "' + p + '"'
|
|
||||||
raise Exception(msg)
|
|
||||||
options = m.group(1)
|
|
||||||
pattern = m.group(2)
|
|
||||||
result = int(m.group(3))
|
|
||||||
|
|
||||||
# Get rid of spaces
|
|
||||||
pattern = pattern.replace(" ", "").replace("\n", "")
|
|
||||||
|
|
||||||
patterns += self._pattern_permute(pattern, options, result)
|
|
||||||
|
|
||||||
# Compile the patterns into regular expressions for speed
|
|
||||||
compiled_patterns = []
|
|
||||||
for pattern in patterns:
|
|
||||||
p = pattern[0].replace(".", "X").replace("X", "[01]")
|
|
||||||
compiled_patterns.append((re.compile(p), pattern[1]))
|
|
||||||
|
|
||||||
# Step through table and find patterns that match.
|
|
||||||
# Note that all the patterns are searched. The last one found takes priority
|
|
||||||
for i in range(LUT_SIZE):
|
|
||||||
# Build the bit pattern
|
|
||||||
bitpattern = bin(i)[2:]
|
|
||||||
bitpattern = ("0" * (9 - len(bitpattern)) + bitpattern)[::-1]
|
|
||||||
|
|
||||||
for pattern, r in compiled_patterns:
|
|
||||||
if pattern.match(bitpattern):
|
|
||||||
self.lut[i] = [0, 1][r]
|
|
||||||
|
|
||||||
return self.lut
|
|
||||||
|
|
||||||
|
|
||||||
class MorphOp:
|
|
||||||
"""A class for binary morphological operators"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
lut: bytearray | None = None,
|
|
||||||
op_name: str | None = None,
|
|
||||||
patterns: list[str] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Create a binary morphological operator.
|
|
||||||
|
|
||||||
If the LUT is not provided, then it is built using LutBuilder from the op_name
|
|
||||||
or the patterns.
|
|
||||||
|
|
||||||
:param lut: The LUT data.
|
|
||||||
:param patterns: A list of input patterns, or None.
|
|
||||||
:param op_name: The name of a known pattern. One of "corner", "dilation4",
|
|
||||||
"dilation8", "erosion4", "erosion8", "edge".
|
|
||||||
:exception Exception: If the op_name is not recognized.
|
|
||||||
"""
|
|
||||||
if patterns is None and op_name is None:
|
|
||||||
self.lut = lut
|
|
||||||
else:
|
|
||||||
self.lut = LutBuilder(patterns, op_name).build_lut()
|
|
||||||
|
|
||||||
def apply(self, image: Image.Image) -> tuple[int, Image.Image]:
|
|
||||||
"""Run a single morphological operation on an image.
|
|
||||||
|
|
||||||
Returns a tuple of the number of changed pixels and the
|
|
||||||
morphed image.
|
|
||||||
|
|
||||||
:param image: A 1-mode or L-mode image.
|
|
||||||
:exception Exception: If the current operator is None.
|
|
||||||
:exception ValueError: If the image is not 1 or L mode."""
|
|
||||||
if self.lut is None:
|
|
||||||
msg = "No operator loaded"
|
|
||||||
raise Exception(msg)
|
|
||||||
|
|
||||||
if image.mode not in ("1", "L"):
|
|
||||||
msg = "Image mode must be 1 or L"
|
|
||||||
raise ValueError(msg)
|
|
||||||
outimage = Image.new(image.mode, image.size)
|
|
||||||
count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim())
|
|
||||||
return count, outimage
|
|
||||||
|
|
||||||
def match(self, image: Image.Image) -> list[tuple[int, int]]:
|
|
||||||
"""Get a list of coordinates matching the morphological operation on
|
|
||||||
an image.
|
|
||||||
|
|
||||||
Returns a list of tuples of (x,y) coordinates of all matching pixels. See
|
|
||||||
:ref:`coordinate-system`.
|
|
||||||
|
|
||||||
:param image: A 1-mode or L-mode image.
|
|
||||||
:exception Exception: If the current operator is None.
|
|
||||||
:exception ValueError: If the image is not 1 or L mode."""
|
|
||||||
if self.lut is None:
|
|
||||||
msg = "No operator loaded"
|
|
||||||
raise Exception(msg)
|
|
||||||
|
|
||||||
if image.mode not in ("1", "L"):
|
|
||||||
msg = "Image mode must be 1 or L"
|
|
||||||
raise ValueError(msg)
|
|
||||||
return _imagingmorph.match(bytes(self.lut), image.getim())
|
|
||||||
|
|
||||||
def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]:
|
|
||||||
"""Get a list of all turned on pixels in a 1 or L mode image.
|
|
||||||
|
|
||||||
Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See
|
|
||||||
:ref:`coordinate-system`.
|
|
||||||
|
|
||||||
:param image: A 1-mode or L-mode image.
|
|
||||||
:exception ValueError: If the image is not 1 or L mode."""
|
|
||||||
|
|
||||||
if image.mode not in ("1", "L"):
|
|
||||||
msg = "Image mode must be 1 or L"
|
|
||||||
raise ValueError(msg)
|
|
||||||
return _imagingmorph.get_on_pixels(image.getim())
|
|
||||||
|
|
||||||
def load_lut(self, filename: str) -> None:
|
|
||||||
"""
|
|
||||||
Load an operator from an mrl file
|
|
||||||
|
|
||||||
:param filename: The file to read from.
|
|
||||||
:exception Exception: If the length of the file data is not 512.
|
|
||||||
"""
|
|
||||||
with open(filename, "rb") as f:
|
|
||||||
self.lut = bytearray(f.read())
|
|
||||||
|
|
||||||
if len(self.lut) != LUT_SIZE:
|
|
||||||
self.lut = None
|
|
||||||
msg = "Wrong size operator file!"
|
|
||||||
raise Exception(msg)
|
|
||||||
|
|
||||||
def save_lut(self, filename: str) -> None:
|
|
||||||
"""
|
|
||||||
Save an operator to an mrl file.
|
|
||||||
|
|
||||||
:param filename: The destination file.
|
|
||||||
:exception Exception: If the current operator is None.
|
|
||||||
"""
|
|
||||||
if self.lut is None:
|
|
||||||
msg = "No operator loaded"
|
|
||||||
raise Exception(msg)
|
|
||||||
with open(filename, "wb") as f:
|
|
||||||
f.write(self.lut)
|
|
||||||
|
|
||||||
def set_lut(self, lut: bytearray | None) -> None:
|
|
||||||
"""
|
|
||||||
Set the LUT from an external source
|
|
||||||
|
|
||||||
:param lut: A new LUT.
|
|
||||||
"""
|
|
||||||
self.lut = lut
|
|
||||||
@@ -1,746 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# standard image operations
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2001-10-20 fl Created
|
|
||||||
# 2001-10-23 fl Added autocontrast operator
|
|
||||||
# 2001-12-18 fl Added Kevin's fit operator
|
|
||||||
# 2004-03-14 fl Fixed potential division by zero in equalize
|
|
||||||
# 2005-05-05 fl Fixed equalize for low number of values
|
|
||||||
#
|
|
||||||
# Copyright (c) 2001-2004 by Secret Labs AB
|
|
||||||
# Copyright (c) 2001-2004 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import functools
|
|
||||||
import operator
|
|
||||||
import re
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from typing import Literal, Protocol, cast, overload
|
|
||||||
|
|
||||||
from . import ExifTags, Image, ImagePalette
|
|
||||||
|
|
||||||
#
|
|
||||||
# helpers
|
|
||||||
|
|
||||||
|
|
||||||
def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]:
|
|
||||||
if isinstance(border, tuple):
|
|
||||||
if len(border) == 2:
|
|
||||||
left, top = right, bottom = border
|
|
||||||
elif len(border) == 4:
|
|
||||||
left, top, right, bottom = border
|
|
||||||
else:
|
|
||||||
left = top = right = bottom = border
|
|
||||||
return left, top, right, bottom
|
|
||||||
|
|
||||||
|
|
||||||
def _color(color: str | int | tuple[int, ...], mode: str) -> int | tuple[int, ...]:
|
|
||||||
if isinstance(color, str):
|
|
||||||
from . import ImageColor
|
|
||||||
|
|
||||||
color = ImageColor.getcolor(color, mode)
|
|
||||||
return color
|
|
||||||
|
|
||||||
|
|
||||||
def _lut(image: Image.Image, lut: list[int]) -> Image.Image:
|
|
||||||
if image.mode == "P":
|
|
||||||
# FIXME: apply to lookup table, not image data
|
|
||||||
msg = "mode P support coming soon"
|
|
||||||
raise NotImplementedError(msg)
|
|
||||||
elif image.mode in ("L", "RGB"):
|
|
||||||
if image.mode == "RGB" and len(lut) == 256:
|
|
||||||
lut = lut + lut + lut
|
|
||||||
return image.point(lut)
|
|
||||||
else:
|
|
||||||
msg = f"not supported for mode {image.mode}"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# actions
|
|
||||||
|
|
||||||
|
|
||||||
def autocontrast(
|
|
||||||
image: Image.Image,
|
|
||||||
cutoff: float | tuple[float, float] = 0,
|
|
||||||
ignore: int | Sequence[int] | None = None,
|
|
||||||
mask: Image.Image | None = None,
|
|
||||||
preserve_tone: bool = False,
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Maximize (normalize) image contrast. This function calculates a
|
|
||||||
histogram of the input image (or mask region), removes ``cutoff`` percent of the
|
|
||||||
lightest and darkest pixels from the histogram, and remaps the image
|
|
||||||
so that the darkest pixel becomes black (0), and the lightest
|
|
||||||
becomes white (255).
|
|
||||||
|
|
||||||
:param image: The image to process.
|
|
||||||
:param cutoff: The percent to cut off from the histogram on the low and
|
|
||||||
high ends. Either a tuple of (low, high), or a single
|
|
||||||
number for both.
|
|
||||||
:param ignore: The background pixel value (use None for no background).
|
|
||||||
:param mask: Histogram used in contrast operation is computed using pixels
|
|
||||||
within the mask. If no mask is given the entire image is used
|
|
||||||
for histogram computation.
|
|
||||||
:param preserve_tone: Preserve image tone in Photoshop-like style autocontrast.
|
|
||||||
|
|
||||||
.. versionadded:: 8.2.0
|
|
||||||
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
if preserve_tone:
|
|
||||||
histogram = image.convert("L").histogram(mask)
|
|
||||||
else:
|
|
||||||
histogram = image.histogram(mask)
|
|
||||||
|
|
||||||
lut = []
|
|
||||||
for layer in range(0, len(histogram), 256):
|
|
||||||
h = histogram[layer : layer + 256]
|
|
||||||
if ignore is not None:
|
|
||||||
# get rid of outliers
|
|
||||||
if isinstance(ignore, int):
|
|
||||||
h[ignore] = 0
|
|
||||||
else:
|
|
||||||
for ix in ignore:
|
|
||||||
h[ix] = 0
|
|
||||||
if cutoff:
|
|
||||||
# cut off pixels from both ends of the histogram
|
|
||||||
if not isinstance(cutoff, tuple):
|
|
||||||
cutoff = (cutoff, cutoff)
|
|
||||||
# get number of pixels
|
|
||||||
n = 0
|
|
||||||
for ix in range(256):
|
|
||||||
n = n + h[ix]
|
|
||||||
# remove cutoff% pixels from the low end
|
|
||||||
cut = int(n * cutoff[0] // 100)
|
|
||||||
for lo in range(256):
|
|
||||||
if cut > h[lo]:
|
|
||||||
cut = cut - h[lo]
|
|
||||||
h[lo] = 0
|
|
||||||
else:
|
|
||||||
h[lo] -= cut
|
|
||||||
cut = 0
|
|
||||||
if cut <= 0:
|
|
||||||
break
|
|
||||||
# remove cutoff% samples from the high end
|
|
||||||
cut = int(n * cutoff[1] // 100)
|
|
||||||
for hi in range(255, -1, -1):
|
|
||||||
if cut > h[hi]:
|
|
||||||
cut = cut - h[hi]
|
|
||||||
h[hi] = 0
|
|
||||||
else:
|
|
||||||
h[hi] -= cut
|
|
||||||
cut = 0
|
|
||||||
if cut <= 0:
|
|
||||||
break
|
|
||||||
# find lowest/highest samples after preprocessing
|
|
||||||
for lo in range(256):
|
|
||||||
if h[lo]:
|
|
||||||
break
|
|
||||||
for hi in range(255, -1, -1):
|
|
||||||
if h[hi]:
|
|
||||||
break
|
|
||||||
if hi <= lo:
|
|
||||||
# don't bother
|
|
||||||
lut.extend(list(range(256)))
|
|
||||||
else:
|
|
||||||
scale = 255.0 / (hi - lo)
|
|
||||||
offset = -lo * scale
|
|
||||||
for ix in range(256):
|
|
||||||
ix = int(ix * scale + offset)
|
|
||||||
if ix < 0:
|
|
||||||
ix = 0
|
|
||||||
elif ix > 255:
|
|
||||||
ix = 255
|
|
||||||
lut.append(ix)
|
|
||||||
return _lut(image, lut)
|
|
||||||
|
|
||||||
|
|
||||||
def colorize(
|
|
||||||
image: Image.Image,
|
|
||||||
black: str | tuple[int, ...],
|
|
||||||
white: str | tuple[int, ...],
|
|
||||||
mid: str | int | tuple[int, ...] | None = None,
|
|
||||||
blackpoint: int = 0,
|
|
||||||
whitepoint: int = 255,
|
|
||||||
midpoint: int = 127,
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Colorize grayscale image.
|
|
||||||
This function calculates a color wedge which maps all black pixels in
|
|
||||||
the source image to the first color and all white pixels to the
|
|
||||||
second color. If ``mid`` is specified, it uses three-color mapping.
|
|
||||||
The ``black`` and ``white`` arguments should be RGB tuples or color names;
|
|
||||||
optionally you can use three-color mapping by also specifying ``mid``.
|
|
||||||
Mapping positions for any of the colors can be specified
|
|
||||||
(e.g. ``blackpoint``), where these parameters are the integer
|
|
||||||
value corresponding to where the corresponding color should be mapped.
|
|
||||||
These parameters must have logical order, such that
|
|
||||||
``blackpoint <= midpoint <= whitepoint`` (if ``mid`` is specified).
|
|
||||||
|
|
||||||
:param image: The image to colorize.
|
|
||||||
:param black: The color to use for black input pixels.
|
|
||||||
:param white: The color to use for white input pixels.
|
|
||||||
:param mid: The color to use for midtone input pixels.
|
|
||||||
:param blackpoint: an int value [0, 255] for the black mapping.
|
|
||||||
:param whitepoint: an int value [0, 255] for the white mapping.
|
|
||||||
:param midpoint: an int value [0, 255] for the midtone mapping.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Initial asserts
|
|
||||||
assert image.mode == "L"
|
|
||||||
if mid is None:
|
|
||||||
assert 0 <= blackpoint <= whitepoint <= 255
|
|
||||||
else:
|
|
||||||
assert 0 <= blackpoint <= midpoint <= whitepoint <= 255
|
|
||||||
|
|
||||||
# Define colors from arguments
|
|
||||||
rgb_black = cast(Sequence[int], _color(black, "RGB"))
|
|
||||||
rgb_white = cast(Sequence[int], _color(white, "RGB"))
|
|
||||||
rgb_mid = cast(Sequence[int], _color(mid, "RGB")) if mid is not None else None
|
|
||||||
|
|
||||||
# Empty lists for the mapping
|
|
||||||
red = []
|
|
||||||
green = []
|
|
||||||
blue = []
|
|
||||||
|
|
||||||
# Create the low-end values
|
|
||||||
for i in range(blackpoint):
|
|
||||||
red.append(rgb_black[0])
|
|
||||||
green.append(rgb_black[1])
|
|
||||||
blue.append(rgb_black[2])
|
|
||||||
|
|
||||||
# Create the mapping (2-color)
|
|
||||||
if rgb_mid is None:
|
|
||||||
range_map = range(whitepoint - blackpoint)
|
|
||||||
|
|
||||||
for i in range_map:
|
|
||||||
red.append(
|
|
||||||
rgb_black[0] + i * (rgb_white[0] - rgb_black[0]) // len(range_map)
|
|
||||||
)
|
|
||||||
green.append(
|
|
||||||
rgb_black[1] + i * (rgb_white[1] - rgb_black[1]) // len(range_map)
|
|
||||||
)
|
|
||||||
blue.append(
|
|
||||||
rgb_black[2] + i * (rgb_white[2] - rgb_black[2]) // len(range_map)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the mapping (3-color)
|
|
||||||
else:
|
|
||||||
range_map1 = range(midpoint - blackpoint)
|
|
||||||
range_map2 = range(whitepoint - midpoint)
|
|
||||||
|
|
||||||
for i in range_map1:
|
|
||||||
red.append(
|
|
||||||
rgb_black[0] + i * (rgb_mid[0] - rgb_black[0]) // len(range_map1)
|
|
||||||
)
|
|
||||||
green.append(
|
|
||||||
rgb_black[1] + i * (rgb_mid[1] - rgb_black[1]) // len(range_map1)
|
|
||||||
)
|
|
||||||
blue.append(
|
|
||||||
rgb_black[2] + i * (rgb_mid[2] - rgb_black[2]) // len(range_map1)
|
|
||||||
)
|
|
||||||
for i in range_map2:
|
|
||||||
red.append(rgb_mid[0] + i * (rgb_white[0] - rgb_mid[0]) // len(range_map2))
|
|
||||||
green.append(
|
|
||||||
rgb_mid[1] + i * (rgb_white[1] - rgb_mid[1]) // len(range_map2)
|
|
||||||
)
|
|
||||||
blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2))
|
|
||||||
|
|
||||||
# Create the high-end values
|
|
||||||
for i in range(256 - whitepoint):
|
|
||||||
red.append(rgb_white[0])
|
|
||||||
green.append(rgb_white[1])
|
|
||||||
blue.append(rgb_white[2])
|
|
||||||
|
|
||||||
# Return converted image
|
|
||||||
image = image.convert("RGB")
|
|
||||||
return _lut(image, red + green + blue)
|
|
||||||
|
|
||||||
|
|
||||||
def contain(
|
|
||||||
image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Returns a resized version of the image, set to the maximum width and height
|
|
||||||
within the requested size, while maintaining the original aspect ratio.
|
|
||||||
|
|
||||||
:param image: The image to resize.
|
|
||||||
:param size: The requested output size in pixels, given as a
|
|
||||||
(width, height) tuple.
|
|
||||||
:param method: Resampling method to use. Default is
|
|
||||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
|
||||||
See :ref:`concept-filters`.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
im_ratio = image.width / image.height
|
|
||||||
dest_ratio = size[0] / size[1]
|
|
||||||
|
|
||||||
if im_ratio != dest_ratio:
|
|
||||||
if im_ratio > dest_ratio:
|
|
||||||
new_height = round(image.height / image.width * size[0])
|
|
||||||
if new_height != size[1]:
|
|
||||||
size = (size[0], new_height)
|
|
||||||
else:
|
|
||||||
new_width = round(image.width / image.height * size[1])
|
|
||||||
if new_width != size[0]:
|
|
||||||
size = (new_width, size[1])
|
|
||||||
return image.resize(size, resample=method)
|
|
||||||
|
|
||||||
|
|
||||||
def cover(
|
|
||||||
image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Returns a resized version of the image, so that the requested size is
|
|
||||||
covered, while maintaining the original aspect ratio.
|
|
||||||
|
|
||||||
:param image: The image to resize.
|
|
||||||
:param size: The requested output size in pixels, given as a
|
|
||||||
(width, height) tuple.
|
|
||||||
:param method: Resampling method to use. Default is
|
|
||||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
|
||||||
See :ref:`concept-filters`.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
im_ratio = image.width / image.height
|
|
||||||
dest_ratio = size[0] / size[1]
|
|
||||||
|
|
||||||
if im_ratio != dest_ratio:
|
|
||||||
if im_ratio < dest_ratio:
|
|
||||||
new_height = round(image.height / image.width * size[0])
|
|
||||||
if new_height != size[1]:
|
|
||||||
size = (size[0], new_height)
|
|
||||||
else:
|
|
||||||
new_width = round(image.width / image.height * size[1])
|
|
||||||
if new_width != size[0]:
|
|
||||||
size = (new_width, size[1])
|
|
||||||
return image.resize(size, resample=method)
|
|
||||||
|
|
||||||
|
|
||||||
def pad(
|
|
||||||
image: Image.Image,
|
|
||||||
size: tuple[int, int],
|
|
||||||
method: int = Image.Resampling.BICUBIC,
|
|
||||||
color: str | int | tuple[int, ...] | None = None,
|
|
||||||
centering: tuple[float, float] = (0.5, 0.5),
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Returns a resized and padded version of the image, expanded to fill the
|
|
||||||
requested aspect ratio and size.
|
|
||||||
|
|
||||||
:param image: The image to resize and crop.
|
|
||||||
:param size: The requested output size in pixels, given as a
|
|
||||||
(width, height) tuple.
|
|
||||||
:param method: Resampling method to use. Default is
|
|
||||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
|
||||||
See :ref:`concept-filters`.
|
|
||||||
:param color: The background color of the padded image.
|
|
||||||
:param centering: Control the position of the original image within the
|
|
||||||
padded version.
|
|
||||||
|
|
||||||
(0.5, 0.5) will keep the image centered
|
|
||||||
(0, 0) will keep the image aligned to the top left
|
|
||||||
(1, 1) will keep the image aligned to the bottom
|
|
||||||
right
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
resized = contain(image, size, method)
|
|
||||||
if resized.size == size:
|
|
||||||
out = resized
|
|
||||||
else:
|
|
||||||
out = Image.new(image.mode, size, color)
|
|
||||||
if resized.palette:
|
|
||||||
palette = resized.getpalette()
|
|
||||||
if palette is not None:
|
|
||||||
out.putpalette(palette)
|
|
||||||
if resized.width != size[0]:
|
|
||||||
x = round((size[0] - resized.width) * max(0, min(centering[0], 1)))
|
|
||||||
out.paste(resized, (x, 0))
|
|
||||||
else:
|
|
||||||
y = round((size[1] - resized.height) * max(0, min(centering[1], 1)))
|
|
||||||
out.paste(resized, (0, y))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def crop(image: Image.Image, border: int = 0) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Remove border from image. The same amount of pixels are removed
|
|
||||||
from all four sides. This function works on all image modes.
|
|
||||||
|
|
||||||
.. seealso:: :py:meth:`~PIL.Image.Image.crop`
|
|
||||||
|
|
||||||
:param image: The image to crop.
|
|
||||||
:param border: The number of pixels to remove.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
left, top, right, bottom = _border(border)
|
|
||||||
return image.crop((left, top, image.size[0] - right, image.size[1] - bottom))
|
|
||||||
|
|
||||||
|
|
||||||
def scale(
|
|
||||||
image: Image.Image, factor: float, resample: int = Image.Resampling.BICUBIC
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Returns a rescaled image by a specific factor given in parameter.
|
|
||||||
A factor greater than 1 expands the image, between 0 and 1 contracts the
|
|
||||||
image.
|
|
||||||
|
|
||||||
:param image: The image to rescale.
|
|
||||||
:param factor: The expansion factor, as a float.
|
|
||||||
:param resample: Resampling method to use. Default is
|
|
||||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
|
||||||
See :ref:`concept-filters`.
|
|
||||||
:returns: An :py:class:`~PIL.Image.Image` object.
|
|
||||||
"""
|
|
||||||
if factor == 1:
|
|
||||||
return image.copy()
|
|
||||||
elif factor <= 0:
|
|
||||||
msg = "the factor must be greater than 0"
|
|
||||||
raise ValueError(msg)
|
|
||||||
else:
|
|
||||||
size = (round(factor * image.width), round(factor * image.height))
|
|
||||||
return image.resize(size, resample)
|
|
||||||
|
|
||||||
|
|
||||||
class SupportsGetMesh(Protocol):
|
|
||||||
"""
|
|
||||||
An object that supports the ``getmesh`` method, taking an image as an
|
|
||||||
argument, and returning a list of tuples. Each tuple contains two tuples,
|
|
||||||
the source box as a tuple of 4 integers, and a tuple of 8 integers for the
|
|
||||||
final quadrilateral, in order of top left, bottom left, bottom right, top
|
|
||||||
right.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def getmesh(
|
|
||||||
self, image: Image.Image
|
|
||||||
) -> list[
|
|
||||||
tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]]
|
|
||||||
]: ...
|
|
||||||
|
|
||||||
|
|
||||||
def deform(
|
|
||||||
image: Image.Image,
|
|
||||||
deformer: SupportsGetMesh,
|
|
||||||
resample: int = Image.Resampling.BILINEAR,
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Deform the image.
|
|
||||||
|
|
||||||
:param image: The image to deform.
|
|
||||||
:param deformer: A deformer object. Any object that implements a
|
|
||||||
``getmesh`` method can be used.
|
|
||||||
:param resample: An optional resampling filter. Same values possible as
|
|
||||||
in the PIL.Image.transform function.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
return image.transform(
|
|
||||||
image.size, Image.Transform.MESH, deformer.getmesh(image), resample
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def equalize(image: Image.Image, mask: Image.Image | None = None) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Equalize the image histogram. This function applies a non-linear
|
|
||||||
mapping to the input image, in order to create a uniform
|
|
||||||
distribution of grayscale values in the output image.
|
|
||||||
|
|
||||||
:param image: The image to equalize.
|
|
||||||
:param mask: An optional mask. If given, only the pixels selected by
|
|
||||||
the mask are included in the analysis.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
if image.mode == "P":
|
|
||||||
image = image.convert("RGB")
|
|
||||||
h = image.histogram(mask)
|
|
||||||
lut = []
|
|
||||||
for b in range(0, len(h), 256):
|
|
||||||
histo = [_f for _f in h[b : b + 256] if _f]
|
|
||||||
if len(histo) <= 1:
|
|
||||||
lut.extend(list(range(256)))
|
|
||||||
else:
|
|
||||||
step = (functools.reduce(operator.add, histo) - histo[-1]) // 255
|
|
||||||
if not step:
|
|
||||||
lut.extend(list(range(256)))
|
|
||||||
else:
|
|
||||||
n = step // 2
|
|
||||||
for i in range(256):
|
|
||||||
lut.append(n // step)
|
|
||||||
n = n + h[i + b]
|
|
||||||
return _lut(image, lut)
|
|
||||||
|
|
||||||
|
|
||||||
def expand(
|
|
||||||
image: Image.Image,
|
|
||||||
border: int | tuple[int, ...] = 0,
|
|
||||||
fill: str | int | tuple[int, ...] = 0,
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Add border to the image
|
|
||||||
|
|
||||||
:param image: The image to expand.
|
|
||||||
:param border: Border width, in pixels.
|
|
||||||
:param fill: Pixel fill value (a color value). Default is 0 (black).
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
left, top, right, bottom = _border(border)
|
|
||||||
width = left + image.size[0] + right
|
|
||||||
height = top + image.size[1] + bottom
|
|
||||||
color = _color(fill, image.mode)
|
|
||||||
if image.palette:
|
|
||||||
mode = image.palette.mode
|
|
||||||
palette = ImagePalette.ImagePalette(mode, image.getpalette(mode))
|
|
||||||
if isinstance(color, tuple) and (len(color) == 3 or len(color) == 4):
|
|
||||||
color = palette.getcolor(color)
|
|
||||||
else:
|
|
||||||
palette = None
|
|
||||||
out = Image.new(image.mode, (width, height), color)
|
|
||||||
if palette:
|
|
||||||
out.putpalette(palette.palette, mode)
|
|
||||||
out.paste(image, (left, top))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def fit(
|
|
||||||
image: Image.Image,
|
|
||||||
size: tuple[int, int],
|
|
||||||
method: int = Image.Resampling.BICUBIC,
|
|
||||||
bleed: float = 0.0,
|
|
||||||
centering: tuple[float, float] = (0.5, 0.5),
|
|
||||||
) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Returns a resized and cropped version of the image, cropped to the
|
|
||||||
requested aspect ratio and size.
|
|
||||||
|
|
||||||
This function was contributed by Kevin Cazabon.
|
|
||||||
|
|
||||||
:param image: The image to resize and crop.
|
|
||||||
:param size: The requested output size in pixels, given as a
|
|
||||||
(width, height) tuple.
|
|
||||||
:param method: Resampling method to use. Default is
|
|
||||||
:py:attr:`~PIL.Image.Resampling.BICUBIC`.
|
|
||||||
See :ref:`concept-filters`.
|
|
||||||
:param bleed: Remove a border around the outside of the image from all
|
|
||||||
four edges. The value is a decimal percentage (use 0.01 for
|
|
||||||
one percent). The default value is 0 (no border).
|
|
||||||
Cannot be greater than or equal to 0.5.
|
|
||||||
:param centering: Control the cropping position. Use (0.5, 0.5) for
|
|
||||||
center cropping (e.g. if cropping the width, take 50% off
|
|
||||||
of the left side, and therefore 50% off the right side).
|
|
||||||
(0.0, 0.0) will crop from the top left corner (i.e. if
|
|
||||||
cropping the width, take all of the crop off of the right
|
|
||||||
side, and if cropping the height, take all of it off the
|
|
||||||
bottom). (1.0, 0.0) will crop from the bottom left
|
|
||||||
corner, etc. (i.e. if cropping the width, take all of the
|
|
||||||
crop off the left side, and if cropping the height take
|
|
||||||
none from the top, and therefore all off the bottom).
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# by Kevin Cazabon, Feb 17/2000
|
|
||||||
# kevin@cazabon.com
|
|
||||||
# https://www.cazabon.com
|
|
||||||
|
|
||||||
centering_x, centering_y = centering
|
|
||||||
|
|
||||||
if not 0.0 <= centering_x <= 1.0:
|
|
||||||
centering_x = 0.5
|
|
||||||
if not 0.0 <= centering_y <= 1.0:
|
|
||||||
centering_y = 0.5
|
|
||||||
|
|
||||||
if not 0.0 <= bleed < 0.5:
|
|
||||||
bleed = 0.0
|
|
||||||
|
|
||||||
# calculate the area to use for resizing and cropping, subtracting
|
|
||||||
# the 'bleed' around the edges
|
|
||||||
|
|
||||||
# number of pixels to trim off on Top and Bottom, Left and Right
|
|
||||||
bleed_pixels = (bleed * image.size[0], bleed * image.size[1])
|
|
||||||
|
|
||||||
live_size = (
|
|
||||||
image.size[0] - bleed_pixels[0] * 2,
|
|
||||||
image.size[1] - bleed_pixels[1] * 2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# calculate the aspect ratio of the live_size
|
|
||||||
live_size_ratio = live_size[0] / live_size[1]
|
|
||||||
|
|
||||||
# calculate the aspect ratio of the output image
|
|
||||||
output_ratio = size[0] / size[1]
|
|
||||||
|
|
||||||
# figure out if the sides or top/bottom will be cropped off
|
|
||||||
if live_size_ratio == output_ratio:
|
|
||||||
# live_size is already the needed ratio
|
|
||||||
crop_width = live_size[0]
|
|
||||||
crop_height = live_size[1]
|
|
||||||
elif live_size_ratio >= output_ratio:
|
|
||||||
# live_size is wider than what's needed, crop the sides
|
|
||||||
crop_width = output_ratio * live_size[1]
|
|
||||||
crop_height = live_size[1]
|
|
||||||
else:
|
|
||||||
# live_size is taller than what's needed, crop the top and bottom
|
|
||||||
crop_width = live_size[0]
|
|
||||||
crop_height = live_size[0] / output_ratio
|
|
||||||
|
|
||||||
# make the crop
|
|
||||||
crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering_x
|
|
||||||
crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering_y
|
|
||||||
|
|
||||||
crop = (crop_left, crop_top, crop_left + crop_width, crop_top + crop_height)
|
|
||||||
|
|
||||||
# resize the image and return it
|
|
||||||
return image.resize(size, method, box=crop)
|
|
||||||
|
|
||||||
|
|
||||||
def flip(image: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Flip the image vertically (top to bottom).
|
|
||||||
|
|
||||||
:param image: The image to flip.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM)
|
|
||||||
|
|
||||||
|
|
||||||
def grayscale(image: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Convert the image to grayscale.
|
|
||||||
|
|
||||||
:param image: The image to convert.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
return image.convert("L")
|
|
||||||
|
|
||||||
|
|
||||||
def invert(image: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Invert (negate) the image.
|
|
||||||
|
|
||||||
:param image: The image to invert.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
lut = list(range(255, -1, -1))
|
|
||||||
return image.point(lut) if image.mode == "1" else _lut(image, lut)
|
|
||||||
|
|
||||||
|
|
||||||
def mirror(image: Image.Image) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Flip image horizontally (left to right).
|
|
||||||
|
|
||||||
:param image: The image to mirror.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
|
||||||
|
|
||||||
|
|
||||||
def posterize(image: Image.Image, bits: int) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Reduce the number of bits for each color channel.
|
|
||||||
|
|
||||||
:param image: The image to posterize.
|
|
||||||
:param bits: The number of bits to keep for each channel (1-8).
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
mask = ~(2 ** (8 - bits) - 1)
|
|
||||||
lut = [i & mask for i in range(256)]
|
|
||||||
return _lut(image, lut)
|
|
||||||
|
|
||||||
|
|
||||||
def solarize(image: Image.Image, threshold: int = 128) -> Image.Image:
|
|
||||||
"""
|
|
||||||
Invert all pixel values above a threshold.
|
|
||||||
|
|
||||||
:param image: The image to solarize.
|
|
||||||
:param threshold: All pixels above this grayscale level are inverted.
|
|
||||||
:return: An image.
|
|
||||||
"""
|
|
||||||
lut = []
|
|
||||||
for i in range(256):
|
|
||||||
if i < threshold:
|
|
||||||
lut.append(i)
|
|
||||||
else:
|
|
||||||
lut.append(255 - i)
|
|
||||||
return _lut(image, lut)
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def exif_transpose(image: Image.Image, *, in_place: Literal[True]) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
|
||||||
def exif_transpose(
|
|
||||||
image: Image.Image, *, in_place: Literal[False] = False
|
|
||||||
) -> Image.Image: ...
|
|
||||||
|
|
||||||
|
|
||||||
def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None:
|
|
||||||
"""
|
|
||||||
If an image has an EXIF Orientation tag, other than 1, transpose the image
|
|
||||||
accordingly, and remove the orientation data.
|
|
||||||
|
|
||||||
:param image: The image to transpose.
|
|
||||||
:param in_place: Boolean. Keyword-only argument.
|
|
||||||
If ``True``, the original image is modified in-place, and ``None`` is returned.
|
|
||||||
If ``False`` (default), a new :py:class:`~PIL.Image.Image` object is returned
|
|
||||||
with the transposition applied. If there is no transposition, a copy of the
|
|
||||||
image will be returned.
|
|
||||||
"""
|
|
||||||
image.load()
|
|
||||||
image_exif = image.getexif()
|
|
||||||
orientation = image_exif.get(ExifTags.Base.Orientation, 1)
|
|
||||||
method = {
|
|
||||||
2: Image.Transpose.FLIP_LEFT_RIGHT,
|
|
||||||
3: Image.Transpose.ROTATE_180,
|
|
||||||
4: Image.Transpose.FLIP_TOP_BOTTOM,
|
|
||||||
5: Image.Transpose.TRANSPOSE,
|
|
||||||
6: Image.Transpose.ROTATE_270,
|
|
||||||
7: Image.Transpose.TRANSVERSE,
|
|
||||||
8: Image.Transpose.ROTATE_90,
|
|
||||||
}.get(orientation)
|
|
||||||
if method is not None:
|
|
||||||
if in_place:
|
|
||||||
image.im = image.im.transpose(method)
|
|
||||||
image._size = image.im.size
|
|
||||||
else:
|
|
||||||
transposed_image = image.transpose(method)
|
|
||||||
exif_image = image if in_place else transposed_image
|
|
||||||
|
|
||||||
exif = exif_image.getexif()
|
|
||||||
if ExifTags.Base.Orientation in exif:
|
|
||||||
del exif[ExifTags.Base.Orientation]
|
|
||||||
if "exif" in exif_image.info:
|
|
||||||
exif_image.info["exif"] = exif.tobytes()
|
|
||||||
elif "Raw profile type exif" in exif_image.info:
|
|
||||||
exif_image.info["Raw profile type exif"] = exif.tobytes().hex()
|
|
||||||
for key in ("XML:com.adobe.xmp", "xmp"):
|
|
||||||
if key in exif_image.info:
|
|
||||||
for pattern in (
|
|
||||||
r'tiff:Orientation="([0-9])"',
|
|
||||||
r"<tiff:Orientation>([0-9])</tiff:Orientation>",
|
|
||||||
):
|
|
||||||
value = exif_image.info[key]
|
|
||||||
if isinstance(value, str):
|
|
||||||
value = re.sub(pattern, "", value)
|
|
||||||
elif isinstance(value, tuple):
|
|
||||||
value = tuple(
|
|
||||||
re.sub(pattern.encode(), b"", v) for v in value
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
value = re.sub(pattern.encode(), b"", value)
|
|
||||||
exif_image.info[key] = value
|
|
||||||
if not in_place:
|
|
||||||
return transposed_image
|
|
||||||
elif not in_place:
|
|
||||||
return image.copy()
|
|
||||||
return None
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# image palette object
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1996-03-11 fl Rewritten.
|
|
||||||
# 1997-01-03 fl Up and running.
|
|
||||||
# 1997-08-23 fl Added load hack
|
|
||||||
# 2001-04-16 fl Fixed randint shadow bug in random()
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2001 by Secret Labs AB
|
|
||||||
# Copyright (c) 1996-1997 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import array
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
|
|
||||||
class ImagePalette:
|
|
||||||
"""
|
|
||||||
Color palette for palette mapped images
|
|
||||||
|
|
||||||
:param mode: The mode to use for the palette. See:
|
|
||||||
:ref:`concept-modes`. Defaults to "RGB"
|
|
||||||
:param palette: An optional palette. If given, it must be a bytearray,
|
|
||||||
an array or a list of ints between 0-255. The list must consist of
|
|
||||||
all channels for one color followed by the next color (e.g. RGBRGBRGB).
|
|
||||||
Defaults to an empty palette.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
mode: str = "RGB",
|
|
||||||
palette: Sequence[int] | bytes | bytearray | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.mode = mode
|
|
||||||
self.rawmode: str | None = None # if set, palette contains raw data
|
|
||||||
self.palette = palette or bytearray()
|
|
||||||
self.dirty: int | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def palette(self) -> Sequence[int] | bytes | bytearray:
|
|
||||||
return self._palette
|
|
||||||
|
|
||||||
@palette.setter
|
|
||||||
def palette(self, palette: Sequence[int] | bytes | bytearray) -> None:
|
|
||||||
self._colors: dict[tuple[int, ...], int] | None = None
|
|
||||||
self._palette = palette
|
|
||||||
|
|
||||||
@property
|
|
||||||
def colors(self) -> dict[tuple[int, ...], int]:
|
|
||||||
if self._colors is None:
|
|
||||||
mode_len = len(self.mode)
|
|
||||||
self._colors = {}
|
|
||||||
for i in range(0, len(self.palette), mode_len):
|
|
||||||
color = tuple(self.palette[i : i + mode_len])
|
|
||||||
if color in self._colors:
|
|
||||||
continue
|
|
||||||
self._colors[color] = i // mode_len
|
|
||||||
return self._colors
|
|
||||||
|
|
||||||
@colors.setter
|
|
||||||
def colors(self, colors: dict[tuple[int, ...], int]) -> None:
|
|
||||||
self._colors = colors
|
|
||||||
|
|
||||||
def copy(self) -> ImagePalette:
|
|
||||||
new = ImagePalette()
|
|
||||||
|
|
||||||
new.mode = self.mode
|
|
||||||
new.rawmode = self.rawmode
|
|
||||||
if self.palette is not None:
|
|
||||||
new.palette = self.palette[:]
|
|
||||||
new.dirty = self.dirty
|
|
||||||
|
|
||||||
return new
|
|
||||||
|
|
||||||
def getdata(self) -> tuple[str, Sequence[int] | bytes | bytearray]:
|
|
||||||
"""
|
|
||||||
Get palette contents in format suitable for the low-level
|
|
||||||
``im.putpalette`` primitive.
|
|
||||||
|
|
||||||
.. warning:: This method is experimental.
|
|
||||||
"""
|
|
||||||
if self.rawmode:
|
|
||||||
return self.rawmode, self.palette
|
|
||||||
return self.mode, self.tobytes()
|
|
||||||
|
|
||||||
def tobytes(self) -> bytes:
|
|
||||||
"""Convert palette to bytes.
|
|
||||||
|
|
||||||
.. warning:: This method is experimental.
|
|
||||||
"""
|
|
||||||
if self.rawmode:
|
|
||||||
msg = "palette contains raw palette data"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if isinstance(self.palette, bytes):
|
|
||||||
return self.palette
|
|
||||||
arr = array.array("B", self.palette)
|
|
||||||
return arr.tobytes()
|
|
||||||
|
|
||||||
# Declare tostring as an alias for tobytes
|
|
||||||
tostring = tobytes
|
|
||||||
|
|
||||||
def _new_color_index(
|
|
||||||
self, image: Image.Image | None = None, e: Exception | None = None
|
|
||||||
) -> int:
|
|
||||||
if not isinstance(self.palette, bytearray):
|
|
||||||
self._palette = bytearray(self.palette)
|
|
||||||
index = len(self.palette) // len(self.mode)
|
|
||||||
special_colors: tuple[int | tuple[int, ...] | None, ...] = ()
|
|
||||||
if image:
|
|
||||||
special_colors = (
|
|
||||||
image.info.get("background"),
|
|
||||||
image.info.get("transparency"),
|
|
||||||
)
|
|
||||||
while index in special_colors:
|
|
||||||
index += 1
|
|
||||||
if index >= 256:
|
|
||||||
if image:
|
|
||||||
# Search for an unused index
|
|
||||||
for i, count in reversed(list(enumerate(image.histogram()))):
|
|
||||||
if count == 0 and i not in special_colors:
|
|
||||||
index = i
|
|
||||||
break
|
|
||||||
if index >= 256:
|
|
||||||
msg = "cannot allocate more than 256 colors"
|
|
||||||
raise ValueError(msg) from e
|
|
||||||
return index
|
|
||||||
|
|
||||||
def getcolor(
|
|
||||||
self,
|
|
||||||
color: tuple[int, ...],
|
|
||||||
image: Image.Image | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Given an rgb tuple, allocate palette entry.
|
|
||||||
|
|
||||||
.. warning:: This method is experimental.
|
|
||||||
"""
|
|
||||||
if self.rawmode:
|
|
||||||
msg = "palette contains raw palette data"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if isinstance(color, tuple):
|
|
||||||
if self.mode == "RGB":
|
|
||||||
if len(color) == 4:
|
|
||||||
if color[3] != 255:
|
|
||||||
msg = "cannot add non-opaque RGBA color to RGB palette"
|
|
||||||
raise ValueError(msg)
|
|
||||||
color = color[:3]
|
|
||||||
elif self.mode == "RGBA":
|
|
||||||
if len(color) == 3:
|
|
||||||
color += (255,)
|
|
||||||
try:
|
|
||||||
return self.colors[color]
|
|
||||||
except KeyError as e:
|
|
||||||
# allocate new color slot
|
|
||||||
index = self._new_color_index(image, e)
|
|
||||||
assert isinstance(self._palette, bytearray)
|
|
||||||
self.colors[color] = index
|
|
||||||
mode_len = len(self.mode)
|
|
||||||
if index * mode_len < len(self.palette):
|
|
||||||
self._palette = (
|
|
||||||
self._palette[: index * mode_len]
|
|
||||||
+ bytes(color)
|
|
||||||
+ self._palette[index * mode_len + mode_len :]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self._palette += bytes(color)
|
|
||||||
self.dirty = 1
|
|
||||||
return index
|
|
||||||
else:
|
|
||||||
msg = f"unknown color specifier: {repr(color)}" # type: ignore[unreachable]
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
def save(self, fp: str | IO[str]) -> None:
|
|
||||||
"""Save palette to text file.
|
|
||||||
|
|
||||||
.. warning:: This method is experimental.
|
|
||||||
"""
|
|
||||||
if self.rawmode:
|
|
||||||
msg = "palette contains raw palette data"
|
|
||||||
raise ValueError(msg)
|
|
||||||
open_fp = False
|
|
||||||
if isinstance(fp, str):
|
|
||||||
fp = open(fp, "w")
|
|
||||||
open_fp = True
|
|
||||||
try:
|
|
||||||
fp.write("# Palette\n")
|
|
||||||
fp.write(f"# Mode: {self.mode}\n")
|
|
||||||
palette_len = len(self.palette)
|
|
||||||
for i in range(256):
|
|
||||||
fp.write(f"{i}")
|
|
||||||
for j in range(i * len(self.mode), (i + 1) * len(self.mode)):
|
|
||||||
fp.write(f" {self.palette[j] if j < palette_len else 0}")
|
|
||||||
fp.write("\n")
|
|
||||||
finally:
|
|
||||||
if open_fp:
|
|
||||||
fp.close()
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Internal
|
|
||||||
|
|
||||||
|
|
||||||
def raw(rawmode: str, data: Sequence[int] | bytes | bytearray) -> ImagePalette:
|
|
||||||
palette = ImagePalette()
|
|
||||||
palette.rawmode = rawmode
|
|
||||||
palette.palette = data
|
|
||||||
palette.dirty = 1
|
|
||||||
return palette
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Factories
|
|
||||||
|
|
||||||
|
|
||||||
def make_linear_lut(black: int, white: float) -> list[int]:
|
|
||||||
if black == 0:
|
|
||||||
return [int(white * i // 255) for i in range(256)]
|
|
||||||
|
|
||||||
msg = "unavailable when black is non-zero"
|
|
||||||
raise NotImplementedError(msg) # FIXME
|
|
||||||
|
|
||||||
|
|
||||||
def make_gamma_lut(exp: float) -> list[int]:
|
|
||||||
return [int(((i / 255.0) ** exp) * 255.0 + 0.5) for i in range(256)]
|
|
||||||
|
|
||||||
|
|
||||||
def negative(mode: str = "RGB") -> ImagePalette:
|
|
||||||
palette = list(range(256 * len(mode)))
|
|
||||||
palette.reverse()
|
|
||||||
return ImagePalette(mode, [i // len(mode) for i in palette])
|
|
||||||
|
|
||||||
|
|
||||||
def random(mode: str = "RGB") -> ImagePalette:
|
|
||||||
from random import randint
|
|
||||||
|
|
||||||
palette = [randint(0, 255) for _ in range(256 * len(mode))]
|
|
||||||
return ImagePalette(mode, palette)
|
|
||||||
|
|
||||||
|
|
||||||
def sepia(white: str = "#fff0c0") -> ImagePalette:
|
|
||||||
bands = [make_linear_lut(0, band) for band in ImageColor.getrgb(white)]
|
|
||||||
return ImagePalette("RGB", [bands[i % 3][i // 3] for i in range(256 * 3)])
|
|
||||||
|
|
||||||
|
|
||||||
def wedge(mode: str = "RGB") -> ImagePalette:
|
|
||||||
palette = list(range(256 * len(mode)))
|
|
||||||
return ImagePalette(mode, [i // len(mode) for i in palette])
|
|
||||||
|
|
||||||
|
|
||||||
def load(filename: str) -> tuple[bytes, str]:
|
|
||||||
# FIXME: supports GIMP gradients only
|
|
||||||
|
|
||||||
with open(filename, "rb") as fp:
|
|
||||||
paletteHandlers: list[
|
|
||||||
type[
|
|
||||||
GimpPaletteFile.GimpPaletteFile
|
|
||||||
| GimpGradientFile.GimpGradientFile
|
|
||||||
| PaletteFile.PaletteFile
|
|
||||||
]
|
|
||||||
] = [
|
|
||||||
GimpPaletteFile.GimpPaletteFile,
|
|
||||||
GimpGradientFile.GimpGradientFile,
|
|
||||||
PaletteFile.PaletteFile,
|
|
||||||
]
|
|
||||||
for paletteHandler in paletteHandlers:
|
|
||||||
try:
|
|
||||||
fp.seek(0)
|
|
||||||
lut = paletteHandler(fp).getpalette()
|
|
||||||
if lut:
|
|
||||||
break
|
|
||||||
except (SyntaxError, ValueError):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
msg = "cannot load palette"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
return lut # data, rawmode
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# path interface
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1996-11-04 fl Created
|
|
||||||
# 2002-04-14 fl Added documentation stub class
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1996.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
Path = Image.core.path
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# a simple Qt image interface.
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 2006-06-03 fl: created
|
|
||||||
# 2006-06-04 fl: inherit from QImage instead of wrapping it
|
|
||||||
# 2006-06-05 fl: removed toimage helper; move string support to ImageQt
|
|
||||||
# 2013-11-13 fl: add support for Qt5 (aurelien.ballier@cyclonit.com)
|
|
||||||
#
|
|
||||||
# Copyright (c) 2006 by Secret Labs AB
|
|
||||||
# Copyright (c) 2006 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
from ._util import is_path
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from . import ImageFile
|
|
||||||
|
|
||||||
QBuffer: type
|
|
||||||
|
|
||||||
qt_version: str | None
|
|
||||||
qt_versions = [
|
|
||||||
["6", "PyQt6"],
|
|
||||||
["side6", "PySide6"],
|
|
||||||
]
|
|
||||||
|
|
||||||
# If a version has already been imported, attempt it first
|
|
||||||
qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True)
|
|
||||||
for version, qt_module in qt_versions:
|
|
||||||
try:
|
|
||||||
qRgba: Callable[[int, int, int, int], int]
|
|
||||||
if qt_module == "PyQt6":
|
|
||||||
from PyQt6.QtCore import QBuffer, QByteArray, QIODevice
|
|
||||||
from PyQt6.QtGui import QImage, QPixmap, qRgba
|
|
||||||
elif qt_module == "PySide6":
|
|
||||||
from PySide6.QtCore import ( # type: ignore[assignment]
|
|
||||||
QBuffer,
|
|
||||||
QByteArray,
|
|
||||||
QIODevice,
|
|
||||||
)
|
|
||||||
from PySide6.QtGui import QImage, QPixmap, qRgba # type: ignore[assignment]
|
|
||||||
except (ImportError, RuntimeError):
|
|
||||||
continue
|
|
||||||
qt_is_installed = True
|
|
||||||
qt_version = version
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
qt_is_installed = False
|
|
||||||
qt_version = None
|
|
||||||
|
|
||||||
|
|
||||||
def rgb(r: int, g: int, b: int, a: int = 255) -> int:
|
|
||||||
"""(Internal) Turns an RGB color into a Qt compatible color integer."""
|
|
||||||
# use qRgb to pack the colors, and then turn the resulting long
|
|
||||||
# into a negative integer with the same bitpattern.
|
|
||||||
return qRgba(r, g, b, a) & 0xFFFFFFFF
|
|
||||||
|
|
||||||
|
|
||||||
def fromqimage(im: QImage | QPixmap) -> ImageFile.ImageFile:
|
|
||||||
"""
|
|
||||||
:param im: QImage or PIL ImageQt object
|
|
||||||
"""
|
|
||||||
buffer = QBuffer()
|
|
||||||
qt_openmode: object
|
|
||||||
if qt_version == "6":
|
|
||||||
try:
|
|
||||||
qt_openmode = getattr(QIODevice, "OpenModeFlag")
|
|
||||||
except AttributeError:
|
|
||||||
qt_openmode = getattr(QIODevice, "OpenMode")
|
|
||||||
else:
|
|
||||||
qt_openmode = QIODevice
|
|
||||||
buffer.open(getattr(qt_openmode, "ReadWrite"))
|
|
||||||
# preserve alpha channel with png
|
|
||||||
# otherwise ppm is more friendly with Image.open
|
|
||||||
if im.hasAlphaChannel():
|
|
||||||
im.save(buffer, "png")
|
|
||||||
else:
|
|
||||||
im.save(buffer, "ppm")
|
|
||||||
|
|
||||||
b = BytesIO()
|
|
||||||
b.write(buffer.data())
|
|
||||||
buffer.close()
|
|
||||||
b.seek(0)
|
|
||||||
|
|
||||||
return Image.open(b)
|
|
||||||
|
|
||||||
|
|
||||||
def fromqpixmap(im: QPixmap) -> ImageFile.ImageFile:
|
|
||||||
return fromqimage(im)
|
|
||||||
|
|
||||||
|
|
||||||
def align8to32(bytes: bytes, width: int, mode: str) -> bytes:
|
|
||||||
"""
|
|
||||||
converts each scanline of data from 8 bit to 32 bit aligned
|
|
||||||
"""
|
|
||||||
|
|
||||||
bits_per_pixel = {"1": 1, "L": 8, "P": 8, "I;16": 16}[mode]
|
|
||||||
|
|
||||||
# calculate bytes per line and the extra padding if needed
|
|
||||||
bits_per_line = bits_per_pixel * width
|
|
||||||
full_bytes_per_line, remaining_bits_per_line = divmod(bits_per_line, 8)
|
|
||||||
bytes_per_line = full_bytes_per_line + (1 if remaining_bits_per_line else 0)
|
|
||||||
|
|
||||||
extra_padding = -bytes_per_line % 4
|
|
||||||
|
|
||||||
# already 32 bit aligned by luck
|
|
||||||
if not extra_padding:
|
|
||||||
return bytes
|
|
||||||
|
|
||||||
new_data = [
|
|
||||||
bytes[i * bytes_per_line : (i + 1) * bytes_per_line] + b"\x00" * extra_padding
|
|
||||||
for i in range(len(bytes) // bytes_per_line)
|
|
||||||
]
|
|
||||||
|
|
||||||
return b"".join(new_data)
|
|
||||||
|
|
||||||
|
|
||||||
def _toqclass_helper(im: Image.Image | str | QByteArray) -> dict[str, Any]:
|
|
||||||
data = None
|
|
||||||
colortable = None
|
|
||||||
exclusive_fp = False
|
|
||||||
|
|
||||||
# handle filename, if given instead of image name
|
|
||||||
if hasattr(im, "toUtf8"):
|
|
||||||
# FIXME - is this really the best way to do this?
|
|
||||||
im = str(im.toUtf8(), "utf-8")
|
|
||||||
if is_path(im):
|
|
||||||
im = Image.open(im)
|
|
||||||
exclusive_fp = True
|
|
||||||
assert isinstance(im, Image.Image)
|
|
||||||
|
|
||||||
qt_format = getattr(QImage, "Format") if qt_version == "6" else QImage
|
|
||||||
if im.mode == "1":
|
|
||||||
format = getattr(qt_format, "Format_Mono")
|
|
||||||
elif im.mode == "L":
|
|
||||||
format = getattr(qt_format, "Format_Indexed8")
|
|
||||||
colortable = [rgb(i, i, i) for i in range(256)]
|
|
||||||
elif im.mode == "P":
|
|
||||||
format = getattr(qt_format, "Format_Indexed8")
|
|
||||||
palette = im.getpalette()
|
|
||||||
assert palette is not None
|
|
||||||
colortable = [rgb(*palette[i : i + 3]) for i in range(0, len(palette), 3)]
|
|
||||||
elif im.mode == "RGB":
|
|
||||||
# Populate the 4th channel with 255
|
|
||||||
im = im.convert("RGBA")
|
|
||||||
|
|
||||||
data = im.tobytes("raw", "BGRA")
|
|
||||||
format = getattr(qt_format, "Format_RGB32")
|
|
||||||
elif im.mode == "RGBA":
|
|
||||||
data = im.tobytes("raw", "BGRA")
|
|
||||||
format = getattr(qt_format, "Format_ARGB32")
|
|
||||||
elif im.mode == "I;16":
|
|
||||||
im = im.point(lambda i: i * 256)
|
|
||||||
|
|
||||||
format = getattr(qt_format, "Format_Grayscale16")
|
|
||||||
else:
|
|
||||||
if exclusive_fp:
|
|
||||||
im.close()
|
|
||||||
msg = f"unsupported image mode {repr(im.mode)}"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
size = im.size
|
|
||||||
__data = data or align8to32(im.tobytes(), size[0], im.mode)
|
|
||||||
if exclusive_fp:
|
|
||||||
im.close()
|
|
||||||
return {"data": __data, "size": size, "format": format, "colortable": colortable}
|
|
||||||
|
|
||||||
|
|
||||||
if qt_is_installed:
|
|
||||||
|
|
||||||
class ImageQt(QImage):
|
|
||||||
def __init__(self, im: Image.Image | str | QByteArray) -> None:
|
|
||||||
"""
|
|
||||||
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
|
|
||||||
class.
|
|
||||||
|
|
||||||
:param im: A PIL Image object, or a file name (given either as
|
|
||||||
Python string or a PyQt string object).
|
|
||||||
"""
|
|
||||||
im_data = _toqclass_helper(im)
|
|
||||||
# must keep a reference, or Qt will crash!
|
|
||||||
# All QImage constructors that take data operate on an existing
|
|
||||||
# buffer, so this buffer has to hang on for the life of the image.
|
|
||||||
# Fixes https://github.com/python-pillow/Pillow/issues/1370
|
|
||||||
self.__data = im_data["data"]
|
|
||||||
super().__init__(
|
|
||||||
self.__data,
|
|
||||||
im_data["size"][0],
|
|
||||||
im_data["size"][1],
|
|
||||||
im_data["format"],
|
|
||||||
)
|
|
||||||
if im_data["colortable"]:
|
|
||||||
self.setColorTable(im_data["colortable"])
|
|
||||||
|
|
||||||
|
|
||||||
def toqimage(im: Image.Image | str | QByteArray) -> ImageQt:
|
|
||||||
return ImageQt(im)
|
|
||||||
|
|
||||||
|
|
||||||
def toqpixmap(im: Image.Image | str | QByteArray) -> QPixmap:
|
|
||||||
qimage = toqimage(im)
|
|
||||||
pixmap = getattr(QPixmap, "fromImage")(qimage)
|
|
||||||
if qt_version == "6":
|
|
||||||
pixmap.detach()
|
|
||||||
return pixmap
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# sequence support classes
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 1997-02-20 fl Created
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997 by Secret Labs AB.
|
|
||||||
# Copyright (c) 1997 by Fredrik Lundh.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
|
|
||||||
##
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
|
|
||||||
class Iterator:
|
|
||||||
"""
|
|
||||||
This class implements an iterator object that can be used to loop
|
|
||||||
over an image sequence.
|
|
||||||
|
|
||||||
You can use the ``[]`` operator to access elements by index. This operator
|
|
||||||
will raise an :py:exc:`IndexError` if you try to access a nonexistent
|
|
||||||
frame.
|
|
||||||
|
|
||||||
:param im: An image object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, im: Image.Image) -> None:
|
|
||||||
if not hasattr(im, "seek"):
|
|
||||||
msg = "im must have seek method"
|
|
||||||
raise AttributeError(msg)
|
|
||||||
self.im = im
|
|
||||||
self.position = getattr(self.im, "_min_frame", 0)
|
|
||||||
|
|
||||||
def __getitem__(self, ix: int) -> Image.Image:
|
|
||||||
try:
|
|
||||||
self.im.seek(ix)
|
|
||||||
return self.im
|
|
||||||
except EOFError as e:
|
|
||||||
msg = "end of sequence"
|
|
||||||
raise IndexError(msg) from e
|
|
||||||
|
|
||||||
def __iter__(self) -> Iterator:
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __next__(self) -> Image.Image:
|
|
||||||
try:
|
|
||||||
self.im.seek(self.position)
|
|
||||||
self.position += 1
|
|
||||||
return self.im
|
|
||||||
except EOFError as e:
|
|
||||||
msg = "end of sequence"
|
|
||||||
raise StopIteration(msg) from e
|
|
||||||
|
|
||||||
|
|
||||||
def all_frames(
|
|
||||||
im: Image.Image | list[Image.Image],
|
|
||||||
func: Callable[[Image.Image], Image.Image] | None = None,
|
|
||||||
) -> list[Image.Image]:
|
|
||||||
"""
|
|
||||||
Applies a given function to all frames in an image or a list of images.
|
|
||||||
The frames are returned as a list of separate images.
|
|
||||||
|
|
||||||
:param im: An image, or a list of images.
|
|
||||||
:param func: The function to apply to all of the image frames.
|
|
||||||
:returns: A list of images.
|
|
||||||
"""
|
|
||||||
if not isinstance(im, list):
|
|
||||||
im = [im]
|
|
||||||
|
|
||||||
ims = []
|
|
||||||
for imSequence in im:
|
|
||||||
current = imSequence.tell()
|
|
||||||
|
|
||||||
ims += [im_frame.copy() for im_frame in Iterator(imSequence)]
|
|
||||||
|
|
||||||
imSequence.seek(current)
|
|
||||||
return [func(im) for im in ims] if func else ims
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# im.show() drivers
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2008-04-06 fl Created
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 2008.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import abc
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from shlex import quote
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
_viewers = []
|
|
||||||
|
|
||||||
|
|
||||||
def register(viewer: type[Viewer] | Viewer, order: int = 1) -> None:
|
|
||||||
"""
|
|
||||||
The :py:func:`register` function is used to register additional viewers::
|
|
||||||
|
|
||||||
from PIL import ImageShow
|
|
||||||
ImageShow.register(MyViewer()) # MyViewer will be used as a last resort
|
|
||||||
ImageShow.register(MySecondViewer(), 0) # MySecondViewer will be prioritised
|
|
||||||
ImageShow.register(ImageShow.XVViewer(), 0) # XVViewer will be prioritised
|
|
||||||
|
|
||||||
:param viewer: The viewer to be registered.
|
|
||||||
:param order:
|
|
||||||
Zero or a negative integer to prepend this viewer to the list,
|
|
||||||
a positive integer to append it.
|
|
||||||
"""
|
|
||||||
if isinstance(viewer, type) and issubclass(viewer, Viewer):
|
|
||||||
viewer = viewer()
|
|
||||||
if order > 0:
|
|
||||||
_viewers.append(viewer)
|
|
||||||
else:
|
|
||||||
_viewers.insert(0, viewer)
|
|
||||||
|
|
||||||
|
|
||||||
def show(image: Image.Image, title: str | None = None, **options: Any) -> bool:
|
|
||||||
r"""
|
|
||||||
Display a given image.
|
|
||||||
|
|
||||||
:param image: An image object.
|
|
||||||
:param title: Optional title. Not all viewers can display the title.
|
|
||||||
:param \**options: Additional viewer options.
|
|
||||||
:returns: ``True`` if a suitable viewer was found, ``False`` otherwise.
|
|
||||||
"""
|
|
||||||
for viewer in _viewers:
|
|
||||||
if viewer.show(image, title=title, **options):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class Viewer:
|
|
||||||
"""Base class for viewers."""
|
|
||||||
|
|
||||||
# main api
|
|
||||||
|
|
||||||
def show(self, image: Image.Image, **options: Any) -> int:
|
|
||||||
"""
|
|
||||||
The main function for displaying an image.
|
|
||||||
Converts the given image to the target format and displays it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not (
|
|
||||||
image.mode in ("1", "RGBA")
|
|
||||||
or (self.format == "PNG" and image.mode in ("I;16", "LA"))
|
|
||||||
):
|
|
||||||
base = Image.getmodebase(image.mode)
|
|
||||||
if image.mode != base:
|
|
||||||
image = image.convert(base)
|
|
||||||
|
|
||||||
return self.show_image(image, **options)
|
|
||||||
|
|
||||||
# hook methods
|
|
||||||
|
|
||||||
format: str | None = None
|
|
||||||
"""The format to convert the image into."""
|
|
||||||
options: dict[str, Any] = {}
|
|
||||||
"""Additional options used to convert the image."""
|
|
||||||
|
|
||||||
def get_format(self, image: Image.Image) -> str | None:
|
|
||||||
"""Return format name, or ``None`` to save as PGM/PPM."""
|
|
||||||
return self.format
|
|
||||||
|
|
||||||
def get_command(self, file: str, **options: Any) -> str:
|
|
||||||
"""
|
|
||||||
Returns the command used to display the file.
|
|
||||||
Not implemented in the base class.
|
|
||||||
"""
|
|
||||||
msg = "unavailable in base viewer"
|
|
||||||
raise NotImplementedError(msg)
|
|
||||||
|
|
||||||
def save_image(self, image: Image.Image) -> str:
|
|
||||||
"""Save to temporary file and return filename."""
|
|
||||||
return image._dump(format=self.get_format(image), **self.options)
|
|
||||||
|
|
||||||
def show_image(self, image: Image.Image, **options: Any) -> int:
|
|
||||||
"""Display the given image."""
|
|
||||||
return self.show_file(self.save_image(image), **options)
|
|
||||||
|
|
||||||
def show_file(self, path: str, **options: Any) -> int:
|
|
||||||
"""
|
|
||||||
Display given file.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise FileNotFoundError
|
|
||||||
os.system(self.get_command(path, **options)) # nosec
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class WindowsViewer(Viewer):
|
|
||||||
"""The default viewer on Windows is the default system application for PNG files."""
|
|
||||||
|
|
||||||
format = "PNG"
|
|
||||||
options = {"compress_level": 1, "save_all": True}
|
|
||||||
|
|
||||||
def get_command(self, file: str, **options: Any) -> str:
|
|
||||||
return (
|
|
||||||
f'start "Pillow" /WAIT "{file}" '
|
|
||||||
"&& ping -n 4 127.0.0.1 >NUL "
|
|
||||||
f'&& del /f "{file}"'
|
|
||||||
)
|
|
||||||
|
|
||||||
def show_file(self, path: str, **options: Any) -> int:
|
|
||||||
"""
|
|
||||||
Display given file.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise FileNotFoundError
|
|
||||||
subprocess.Popen(
|
|
||||||
self.get_command(path, **options),
|
|
||||||
shell=True,
|
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW"),
|
|
||||||
) # nosec
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if sys.platform == "win32":
|
|
||||||
register(WindowsViewer)
|
|
||||||
|
|
||||||
|
|
||||||
class MacViewer(Viewer):
|
|
||||||
"""The default viewer on macOS using ``Preview.app``."""
|
|
||||||
|
|
||||||
format = "PNG"
|
|
||||||
options = {"compress_level": 1, "save_all": True}
|
|
||||||
|
|
||||||
def get_command(self, file: str, **options: Any) -> str:
|
|
||||||
# on darwin open returns immediately resulting in the temp
|
|
||||||
# file removal while app is opening
|
|
||||||
command = "open -a Preview.app"
|
|
||||||
command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&"
|
|
||||||
return command
|
|
||||||
|
|
||||||
def show_file(self, path: str, **options: Any) -> int:
|
|
||||||
"""
|
|
||||||
Display given file.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise FileNotFoundError
|
|
||||||
subprocess.call(["open", "-a", "Preview.app", path])
|
|
||||||
|
|
||||||
pyinstaller = getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
|
|
||||||
executable = (not pyinstaller and sys.executable) or shutil.which("python3")
|
|
||||||
if executable:
|
|
||||||
subprocess.Popen(
|
|
||||||
[
|
|
||||||
executable,
|
|
||||||
"-c",
|
|
||||||
"import os, sys, time; time.sleep(20); os.remove(sys.argv[1])",
|
|
||||||
path,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if sys.platform == "darwin":
|
|
||||||
register(MacViewer)
|
|
||||||
|
|
||||||
|
|
||||||
class UnixViewer(abc.ABC, Viewer):
|
|
||||||
format = "PNG"
|
|
||||||
options = {"compress_level": 1, "save_all": True}
|
|
||||||
|
|
||||||
@abc.abstractmethod
|
|
||||||
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_command(self, file: str, **options: Any) -> str:
|
|
||||||
command = self.get_command_ex(file, **options)[0]
|
|
||||||
return f"{command} {quote(file)}"
|
|
||||||
|
|
||||||
|
|
||||||
class XDGViewer(UnixViewer):
|
|
||||||
"""
|
|
||||||
The freedesktop.org ``xdg-open`` command.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
|
|
||||||
command = executable = "xdg-open"
|
|
||||||
return command, executable
|
|
||||||
|
|
||||||
def show_file(self, path: str, **options: Any) -> int:
|
|
||||||
"""
|
|
||||||
Display given file.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise FileNotFoundError
|
|
||||||
subprocess.Popen(["xdg-open", path])
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayViewer(UnixViewer):
|
|
||||||
"""
|
|
||||||
The ImageMagick ``display`` command.
|
|
||||||
This viewer supports the ``title`` parameter.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_command_ex(
|
|
||||||
self, file: str, title: str | None = None, **options: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
command = executable = "display"
|
|
||||||
if title:
|
|
||||||
command += f" -title {quote(title)}"
|
|
||||||
return command, executable
|
|
||||||
|
|
||||||
def show_file(self, path: str, **options: Any) -> int:
|
|
||||||
"""
|
|
||||||
Display given file.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise FileNotFoundError
|
|
||||||
args = ["display"]
|
|
||||||
title = options.get("title")
|
|
||||||
if title:
|
|
||||||
args += ["-title", title]
|
|
||||||
args.append(path)
|
|
||||||
|
|
||||||
subprocess.Popen(args)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
class GmDisplayViewer(UnixViewer):
|
|
||||||
"""The GraphicsMagick ``gm display`` command."""
|
|
||||||
|
|
||||||
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
|
|
||||||
executable = "gm"
|
|
||||||
command = "gm display"
|
|
||||||
return command, executable
|
|
||||||
|
|
||||||
def show_file(self, path: str, **options: Any) -> int:
|
|
||||||
"""
|
|
||||||
Display given file.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise FileNotFoundError
|
|
||||||
subprocess.Popen(["gm", "display", path])
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
class EogViewer(UnixViewer):
|
|
||||||
"""The GNOME Image Viewer ``eog`` command."""
|
|
||||||
|
|
||||||
def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]:
|
|
||||||
executable = "eog"
|
|
||||||
command = "eog -n"
|
|
||||||
return command, executable
|
|
||||||
|
|
||||||
def show_file(self, path: str, **options: Any) -> int:
|
|
||||||
"""
|
|
||||||
Display given file.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise FileNotFoundError
|
|
||||||
subprocess.Popen(["eog", "-n", path])
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
class XVViewer(UnixViewer):
|
|
||||||
"""
|
|
||||||
The X Viewer ``xv`` command.
|
|
||||||
This viewer supports the ``title`` parameter.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def get_command_ex(
|
|
||||||
self, file: str, title: str | None = None, **options: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
# note: xv is pretty outdated. most modern systems have
|
|
||||||
# imagemagick's display command instead.
|
|
||||||
command = executable = "xv"
|
|
||||||
if title:
|
|
||||||
command += f" -name {quote(title)}"
|
|
||||||
return command, executable
|
|
||||||
|
|
||||||
def show_file(self, path: str, **options: Any) -> int:
|
|
||||||
"""
|
|
||||||
Display given file.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise FileNotFoundError
|
|
||||||
args = ["xv"]
|
|
||||||
title = options.get("title")
|
|
||||||
if title:
|
|
||||||
args += ["-name", title]
|
|
||||||
args.append(path)
|
|
||||||
|
|
||||||
subprocess.Popen(args)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if sys.platform not in ("win32", "darwin"): # unixoids
|
|
||||||
if shutil.which("xdg-open"):
|
|
||||||
register(XDGViewer)
|
|
||||||
if shutil.which("display"):
|
|
||||||
register(DisplayViewer)
|
|
||||||
if shutil.which("gm"):
|
|
||||||
register(GmDisplayViewer)
|
|
||||||
if shutil.which("eog"):
|
|
||||||
register(EogViewer)
|
|
||||||
if shutil.which("xv"):
|
|
||||||
register(XVViewer)
|
|
||||||
|
|
||||||
|
|
||||||
class IPythonViewer(Viewer):
|
|
||||||
"""The viewer for IPython frontends."""
|
|
||||||
|
|
||||||
def show_image(self, image: Image.Image, **options: Any) -> int:
|
|
||||||
ipython_display(image)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
|
||||||
from IPython.display import display as ipython_display
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
register(IPythonViewer)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) < 2:
|
|
||||||
print("Syntax: python3 ImageShow.py imagefile [title]")
|
|
||||||
sys.exit()
|
|
||||||
|
|
||||||
with Image.open(sys.argv[1]) as im:
|
|
||||||
print(show(im, *sys.argv[2:]))
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# global image statistics
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1996-04-05 fl Created
|
|
||||||
# 1997-05-21 fl Added mask; added rms, var, stddev attributes
|
|
||||||
# 1997-08-05 fl Added median
|
|
||||||
# 1998-07-05 hk Fixed integer overflow error
|
|
||||||
#
|
|
||||||
# Notes:
|
|
||||||
# This class shows how to implement delayed evaluation of attributes.
|
|
||||||
# To get a certain value, simply access the corresponding attribute.
|
|
||||||
# The __getattr__ dispatcher takes care of the rest.
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1996-97.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
from functools import cached_property
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
|
|
||||||
class Stat:
|
|
||||||
def __init__(
|
|
||||||
self, image_or_list: Image.Image | list[int], mask: Image.Image | None = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Calculate statistics for the given image. If a mask is included,
|
|
||||||
only the regions covered by that mask are included in the
|
|
||||||
statistics. You can also pass in a previously calculated histogram.
|
|
||||||
|
|
||||||
:param image: A PIL image, or a precalculated histogram.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
For a PIL image, calculations rely on the
|
|
||||||
:py:meth:`~PIL.Image.Image.histogram` method. The pixel counts are
|
|
||||||
grouped into 256 bins, even if the image has more than 8 bits per
|
|
||||||
channel. So ``I`` and ``F`` mode images have a maximum ``mean``,
|
|
||||||
``median`` and ``rms`` of 255, and cannot have an ``extrema`` maximum
|
|
||||||
of more than 255.
|
|
||||||
|
|
||||||
:param mask: An optional mask.
|
|
||||||
"""
|
|
||||||
if isinstance(image_or_list, Image.Image):
|
|
||||||
self.h = image_or_list.histogram(mask)
|
|
||||||
elif isinstance(image_or_list, list):
|
|
||||||
self.h = image_or_list
|
|
||||||
else:
|
|
||||||
msg = "first argument must be image or list" # type: ignore[unreachable]
|
|
||||||
raise TypeError(msg)
|
|
||||||
self.bands = list(range(len(self.h) // 256))
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def extrema(self) -> list[tuple[int, int]]:
|
|
||||||
"""
|
|
||||||
Min/max values for each band in the image.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
This relies on the :py:meth:`~PIL.Image.Image.histogram` method, and
|
|
||||||
simply returns the low and high bins used. This is correct for
|
|
||||||
images with 8 bits per channel, but fails for other modes such as
|
|
||||||
``I`` or ``F``. Instead, use :py:meth:`~PIL.Image.Image.getextrema` to
|
|
||||||
return per-band extrema for the image. This is more correct and
|
|
||||||
efficient because, for non-8-bit modes, the histogram method uses
|
|
||||||
:py:meth:`~PIL.Image.Image.getextrema` to determine the bins used.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def minmax(histogram: list[int]) -> tuple[int, int]:
|
|
||||||
res_min, res_max = 255, 0
|
|
||||||
for i in range(256):
|
|
||||||
if histogram[i]:
|
|
||||||
res_min = i
|
|
||||||
break
|
|
||||||
for i in range(255, -1, -1):
|
|
||||||
if histogram[i]:
|
|
||||||
res_max = i
|
|
||||||
break
|
|
||||||
return res_min, res_max
|
|
||||||
|
|
||||||
return [minmax(self.h[i:]) for i in range(0, len(self.h), 256)]
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def count(self) -> list[int]:
|
|
||||||
"""Total number of pixels for each band in the image."""
|
|
||||||
return [sum(self.h[i : i + 256]) for i in range(0, len(self.h), 256)]
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def sum(self) -> list[float]:
|
|
||||||
"""Sum of all pixels for each band in the image."""
|
|
||||||
|
|
||||||
v = []
|
|
||||||
for i in range(0, len(self.h), 256):
|
|
||||||
layer_sum = 0.0
|
|
||||||
for j in range(256):
|
|
||||||
layer_sum += j * self.h[i + j]
|
|
||||||
v.append(layer_sum)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def sum2(self) -> list[float]:
|
|
||||||
"""Squared sum of all pixels for each band in the image."""
|
|
||||||
|
|
||||||
v = []
|
|
||||||
for i in range(0, len(self.h), 256):
|
|
||||||
sum2 = 0.0
|
|
||||||
for j in range(256):
|
|
||||||
sum2 += (j**2) * float(self.h[i + j])
|
|
||||||
v.append(sum2)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def mean(self) -> list[float]:
|
|
||||||
"""Average (arithmetic mean) pixel level for each band in the image."""
|
|
||||||
return [self.sum[i] / self.count[i] if self.count[i] else 0 for i in self.bands]
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def median(self) -> list[int]:
|
|
||||||
"""Median pixel level for each band in the image."""
|
|
||||||
|
|
||||||
v = []
|
|
||||||
for i in self.bands:
|
|
||||||
s = 0
|
|
||||||
half = self.count[i] // 2
|
|
||||||
b = i * 256
|
|
||||||
for j in range(256):
|
|
||||||
s = s + self.h[b + j]
|
|
||||||
if s > half:
|
|
||||||
break
|
|
||||||
v.append(j)
|
|
||||||
return v
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def rms(self) -> list[float]:
|
|
||||||
"""RMS (root-mean-square) for each band in the image."""
|
|
||||||
return [
|
|
||||||
math.sqrt(self.sum2[i] / self.count[i]) if self.count[i] else 0
|
|
||||||
for i in self.bands
|
|
||||||
]
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def var(self) -> list[float]:
|
|
||||||
"""Variance for each band in the image."""
|
|
||||||
return [
|
|
||||||
(
|
|
||||||
(self.sum2[i] - (self.sum[i] ** 2.0) / self.count[i]) / self.count[i]
|
|
||||||
if self.count[i]
|
|
||||||
else 0
|
|
||||||
)
|
|
||||||
for i in self.bands
|
|
||||||
]
|
|
||||||
|
|
||||||
@cached_property
|
|
||||||
def stddev(self) -> list[float]:
|
|
||||||
"""Standard deviation for each band in the image."""
|
|
||||||
return [math.sqrt(self.var[i]) for i in self.bands]
|
|
||||||
|
|
||||||
|
|
||||||
Global = Stat # compatibility
|
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
import re
|
|
||||||
from typing import AnyStr, Generic, NamedTuple
|
|
||||||
|
|
||||||
from . import ImageFont
|
|
||||||
from ._typing import _Ink
|
|
||||||
|
|
||||||
Font = ImageFont.ImageFont | ImageFont.FreeTypeFont | ImageFont.TransposedFont
|
|
||||||
|
|
||||||
|
|
||||||
class _Line(NamedTuple):
|
|
||||||
x: float
|
|
||||||
y: float
|
|
||||||
anchor: str
|
|
||||||
text: str | bytes
|
|
||||||
|
|
||||||
|
|
||||||
class _Wrap(Generic[AnyStr]):
|
|
||||||
lines: list[AnyStr] = []
|
|
||||||
position = 0
|
|
||||||
offset = 0
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
text: Text[AnyStr],
|
|
||||||
width: int,
|
|
||||||
height: int | None = None,
|
|
||||||
font: Font | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.text: Text[AnyStr] = text
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.font = font
|
|
||||||
|
|
||||||
input_text = self.text.text
|
|
||||||
emptystring = "" if isinstance(input_text, str) else b""
|
|
||||||
line = emptystring
|
|
||||||
|
|
||||||
for word in re.findall(
|
|
||||||
r"\s*\S+" if isinstance(input_text, str) else rb"\s*\S+", input_text
|
|
||||||
):
|
|
||||||
newlines = re.findall(
|
|
||||||
r"[^\S\n]*\n" if isinstance(input_text, str) else rb"[^\S\n]*\n", word
|
|
||||||
)
|
|
||||||
if newlines:
|
|
||||||
if not self.add_line(line):
|
|
||||||
break
|
|
||||||
for i, line in enumerate(newlines):
|
|
||||||
if i != 0 and not self.add_line(emptystring):
|
|
||||||
break
|
|
||||||
self.position += len(line)
|
|
||||||
word = word[len(line) :]
|
|
||||||
line = emptystring
|
|
||||||
|
|
||||||
new_line = line + word
|
|
||||||
if self.text._get_bbox(new_line, self.font)[2] <= width:
|
|
||||||
# This word fits on the line
|
|
||||||
line = new_line
|
|
||||||
continue
|
|
||||||
|
|
||||||
# This word does not fit on the line
|
|
||||||
if line and not self.add_line(line):
|
|
||||||
break
|
|
||||||
|
|
||||||
original_length = len(word)
|
|
||||||
word = word.lstrip()
|
|
||||||
self.offset = original_length - len(word)
|
|
||||||
|
|
||||||
if self.text._get_bbox(word, self.font)[2] > width:
|
|
||||||
if font is None:
|
|
||||||
msg = "Word does not fit within line"
|
|
||||||
raise ValueError(msg)
|
|
||||||
break
|
|
||||||
line = word
|
|
||||||
else:
|
|
||||||
if line:
|
|
||||||
self.add_line(line)
|
|
||||||
self.remaining_text: AnyStr = input_text[self.position :]
|
|
||||||
|
|
||||||
def add_line(self, line: AnyStr) -> bool:
|
|
||||||
lines = self.lines + [line]
|
|
||||||
if self.height is not None:
|
|
||||||
last_line_y = self.text._split(lines=lines)[-1].y
|
|
||||||
last_line_height = self.text._get_bbox(line, self.font)[3]
|
|
||||||
if last_line_y + last_line_height > self.height:
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.lines = lines
|
|
||||||
self.position += len(line) + self.offset
|
|
||||||
self.offset = 0
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class Text(Generic[AnyStr]):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
text: AnyStr,
|
|
||||||
font: Font | None = None,
|
|
||||||
mode: str = "RGB",
|
|
||||||
spacing: float = 4,
|
|
||||||
direction: str | None = None,
|
|
||||||
features: list[str] | None = None,
|
|
||||||
language: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
:param text: String to be drawn.
|
|
||||||
:param font: Either an :py:class:`~PIL.ImageFont.ImageFont` instance,
|
|
||||||
:py:class:`~PIL.ImageFont.FreeTypeFont` instance,
|
|
||||||
:py:class:`~PIL.ImageFont.TransposedFont` instance or ``None``. If
|
|
||||||
``None``, the default font from :py:meth:`.ImageFont.load_default`
|
|
||||||
will be used.
|
|
||||||
:param mode: The image mode this will be used with.
|
|
||||||
:param spacing: The number of pixels between lines.
|
|
||||||
:param direction: Direction of the text. It can be ``"rtl"`` (right to left),
|
|
||||||
``"ltr"`` (left to right) or ``"ttb"`` (top to bottom).
|
|
||||||
Requires libraqm.
|
|
||||||
:param features: A list of OpenType font features to be used during text
|
|
||||||
layout. This is usually used to turn on optional font features
|
|
||||||
that are not enabled by default, for example ``"dlig"`` or
|
|
||||||
``"ss01"``, but can be also used to turn off default font
|
|
||||||
features, for example ``"-liga"`` to disable ligatures or
|
|
||||||
``"-kern"`` to disable kerning. To get all supported
|
|
||||||
features, see `OpenType docs`_.
|
|
||||||
Requires libraqm.
|
|
||||||
:param language: Language of the text. Different languages may use
|
|
||||||
different glyph shapes or ligatures. This parameter tells
|
|
||||||
the font which language the text is in, and to apply the
|
|
||||||
correct substitutions as appropriate, if available.
|
|
||||||
It should be a `BCP 47 language code`_.
|
|
||||||
Requires libraqm.
|
|
||||||
"""
|
|
||||||
self.text: AnyStr = text
|
|
||||||
self.font = font or ImageFont.load_default()
|
|
||||||
|
|
||||||
self.mode = mode
|
|
||||||
self.spacing = spacing
|
|
||||||
self.direction = direction
|
|
||||||
self.features = features
|
|
||||||
self.language = language
|
|
||||||
|
|
||||||
self.embedded_color = False
|
|
||||||
|
|
||||||
self.stroke_width: float = 0
|
|
||||||
self.stroke_fill: _Ink | None = None
|
|
||||||
|
|
||||||
def embed_color(self) -> None:
|
|
||||||
"""
|
|
||||||
Use embedded color glyphs (COLR, CBDT, SBIX).
|
|
||||||
"""
|
|
||||||
if self.mode not in ("RGB", "RGBA"):
|
|
||||||
msg = "Embedded color supported only in RGB and RGBA modes"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.embedded_color = True
|
|
||||||
|
|
||||||
def stroke(self, width: float = 0, fill: _Ink | None = None) -> None:
|
|
||||||
"""
|
|
||||||
:param width: The width of the text stroke.
|
|
||||||
:param fill: Color to use for the text stroke when drawing. If not given, will
|
|
||||||
default to the ``fill`` parameter from
|
|
||||||
:py:meth:`.ImageDraw.ImageDraw.text`.
|
|
||||||
"""
|
|
||||||
self.stroke_width = width
|
|
||||||
self.stroke_fill = fill
|
|
||||||
|
|
||||||
def _get_fontmode(self) -> str:
|
|
||||||
if self.mode in ("1", "P", "I", "F"):
|
|
||||||
return "1"
|
|
||||||
elif self.embedded_color:
|
|
||||||
return "RGBA"
|
|
||||||
else:
|
|
||||||
return "L"
|
|
||||||
|
|
||||||
def wrap(
|
|
||||||
self,
|
|
||||||
width: int,
|
|
||||||
height: int | None = None,
|
|
||||||
scaling: str | tuple[str, int] | None = None,
|
|
||||||
) -> Text[AnyStr] | None:
|
|
||||||
"""
|
|
||||||
Wrap text to fit within a given width.
|
|
||||||
|
|
||||||
:param width: The width to fit within.
|
|
||||||
:param height: An optional height limit. Any text that does not fit within this
|
|
||||||
will be returned as a new :py:class:`.Text` object.
|
|
||||||
:param scaling: An optional directive to scale the text, either "grow" as much
|
|
||||||
as possible within the given dimensions, or "shrink" until it
|
|
||||||
fits. It can also be a tuple of (direction, limit), with an
|
|
||||||
integer limit to stop scaling at.
|
|
||||||
|
|
||||||
:returns: An :py:class:`.Text` object, or None.
|
|
||||||
"""
|
|
||||||
if isinstance(self.font, ImageFont.TransposedFont):
|
|
||||||
msg = "TransposedFont not supported"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if self.direction not in (None, "ltr"):
|
|
||||||
msg = "Only ltr direction supported"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
if scaling is None:
|
|
||||||
wrap = _Wrap(self, width, height)
|
|
||||||
else:
|
|
||||||
if not isinstance(self.font, ImageFont.FreeTypeFont):
|
|
||||||
msg = "'scaling' only supports FreeTypeFont"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if height is None:
|
|
||||||
msg = "'scaling' requires 'height'"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
if isinstance(scaling, str):
|
|
||||||
limit = 1
|
|
||||||
else:
|
|
||||||
scaling, limit = scaling
|
|
||||||
|
|
||||||
font = self.font
|
|
||||||
wrap = _Wrap(self, width, height, font)
|
|
||||||
if scaling == "shrink":
|
|
||||||
if not wrap.remaining_text:
|
|
||||||
return None
|
|
||||||
|
|
||||||
size = math.ceil(font.size)
|
|
||||||
while wrap.remaining_text:
|
|
||||||
if size == max(limit, 1):
|
|
||||||
msg = "Text could not be scaled"
|
|
||||||
raise ValueError(msg)
|
|
||||||
size -= 1
|
|
||||||
font = self.font.font_variant(size=size)
|
|
||||||
wrap = _Wrap(self, width, height, font)
|
|
||||||
self.font = font
|
|
||||||
else:
|
|
||||||
if wrap.remaining_text:
|
|
||||||
msg = "Text could not be scaled"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
size = math.floor(font.size)
|
|
||||||
while not wrap.remaining_text:
|
|
||||||
if size == limit:
|
|
||||||
msg = "Text could not be scaled"
|
|
||||||
raise ValueError(msg)
|
|
||||||
size += 1
|
|
||||||
font = self.font.font_variant(size=size)
|
|
||||||
last_wrap = wrap
|
|
||||||
wrap = _Wrap(self, width, height, font)
|
|
||||||
size -= 1
|
|
||||||
if size != self.font.size:
|
|
||||||
self.font = self.font.font_variant(size=size)
|
|
||||||
wrap = last_wrap
|
|
||||||
|
|
||||||
if wrap.remaining_text:
|
|
||||||
text = Text(
|
|
||||||
text=wrap.remaining_text,
|
|
||||||
font=self.font,
|
|
||||||
mode=self.mode,
|
|
||||||
spacing=self.spacing,
|
|
||||||
direction=self.direction,
|
|
||||||
features=self.features,
|
|
||||||
language=self.language,
|
|
||||||
)
|
|
||||||
text.embedded_color = self.embedded_color
|
|
||||||
text.stroke_width = self.stroke_width
|
|
||||||
text.stroke_fill = self.stroke_fill
|
|
||||||
else:
|
|
||||||
text = None
|
|
||||||
|
|
||||||
newline = "\n" if isinstance(self.text, str) else b"\n"
|
|
||||||
self.text = newline.join(wrap.lines)
|
|
||||||
return text
|
|
||||||
|
|
||||||
def get_length(self) -> float:
|
|
||||||
"""
|
|
||||||
Returns length (in pixels with 1/64 precision) of text.
|
|
||||||
|
|
||||||
This is the amount by which following text should be offset.
|
|
||||||
Text bounding box may extend past the length in some fonts,
|
|
||||||
e.g. when using italics or accents.
|
|
||||||
|
|
||||||
The result is returned as a float; it is a whole number if using basic layout.
|
|
||||||
|
|
||||||
Note that the sum of two lengths may not equal the length of a concatenated
|
|
||||||
string due to kerning. If you need to adjust for kerning, include the following
|
|
||||||
character and subtract its length.
|
|
||||||
|
|
||||||
For example, instead of::
|
|
||||||
|
|
||||||
hello = ImageText.Text("Hello", font).get_length()
|
|
||||||
world = ImageText.Text("World", font).get_length()
|
|
||||||
helloworld = ImageText.Text("HelloWorld", font).get_length()
|
|
||||||
assert hello + world == helloworld
|
|
||||||
|
|
||||||
use::
|
|
||||||
|
|
||||||
hello = (
|
|
||||||
ImageText.Text("HelloW", font).get_length() -
|
|
||||||
ImageText.Text("W", font).get_length()
|
|
||||||
) # adjusted for kerning
|
|
||||||
world = ImageText.Text("World", font).get_length()
|
|
||||||
helloworld = ImageText.Text("HelloWorld", font).get_length()
|
|
||||||
assert hello + world == helloworld
|
|
||||||
|
|
||||||
or disable kerning with (requires libraqm)::
|
|
||||||
|
|
||||||
hello = ImageText.Text("Hello", font, features=["-kern"]).get_length()
|
|
||||||
world = ImageText.Text("World", font, features=["-kern"]).get_length()
|
|
||||||
helloworld = ImageText.Text(
|
|
||||||
"HelloWorld", font, features=["-kern"]
|
|
||||||
).get_length()
|
|
||||||
assert hello + world == helloworld
|
|
||||||
|
|
||||||
:return: Either width for horizontal text, or height for vertical text.
|
|
||||||
"""
|
|
||||||
if isinstance(self.text, str):
|
|
||||||
multiline = "\n" in self.text
|
|
||||||
else:
|
|
||||||
multiline = b"\n" in self.text
|
|
||||||
if multiline:
|
|
||||||
msg = "can't measure length of multiline text"
|
|
||||||
raise ValueError(msg)
|
|
||||||
return self.font.getlength(
|
|
||||||
self.text,
|
|
||||||
self._get_fontmode(),
|
|
||||||
self.direction,
|
|
||||||
self.features,
|
|
||||||
self.language,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _split(
|
|
||||||
self,
|
|
||||||
xy: tuple[float, float] = (0, 0),
|
|
||||||
anchor: str | None = None,
|
|
||||||
align: str = "left",
|
|
||||||
lines: list[str] | list[bytes] | None = None,
|
|
||||||
) -> list[_Line]:
|
|
||||||
if anchor is None:
|
|
||||||
anchor = "lt" if self.direction == "ttb" else "la"
|
|
||||||
elif len(anchor) != 2:
|
|
||||||
msg = "anchor must be a 2 character string"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
if lines is None:
|
|
||||||
lines = (
|
|
||||||
self.text.split("\n")
|
|
||||||
if isinstance(self.text, str)
|
|
||||||
else self.text.split(b"\n")
|
|
||||||
)
|
|
||||||
if len(lines) == 1:
|
|
||||||
return [_Line(xy[0], xy[1], anchor, lines[0])]
|
|
||||||
|
|
||||||
if anchor[1] in "tb" and self.direction != "ttb":
|
|
||||||
msg = "anchor not supported for multiline text"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
fontmode = self._get_fontmode()
|
|
||||||
line_spacing = (
|
|
||||||
self.font.getbbox(
|
|
||||||
"A",
|
|
||||||
fontmode,
|
|
||||||
None,
|
|
||||||
self.features,
|
|
||||||
self.language,
|
|
||||||
self.stroke_width,
|
|
||||||
)[3]
|
|
||||||
+ self.stroke_width
|
|
||||||
+ self.spacing
|
|
||||||
)
|
|
||||||
|
|
||||||
top = xy[1]
|
|
||||||
parts = []
|
|
||||||
if self.direction == "ttb":
|
|
||||||
left = xy[0]
|
|
||||||
for line in lines:
|
|
||||||
parts.append(_Line(left, top, anchor, line))
|
|
||||||
left += line_spacing
|
|
||||||
else:
|
|
||||||
widths = []
|
|
||||||
max_width: float = 0
|
|
||||||
for line in lines:
|
|
||||||
line_width = self.font.getlength(
|
|
||||||
line, fontmode, self.direction, self.features, self.language
|
|
||||||
)
|
|
||||||
widths.append(line_width)
|
|
||||||
max_width = max(max_width, line_width)
|
|
||||||
|
|
||||||
if anchor[1] == "m":
|
|
||||||
top -= (len(lines) - 1) * line_spacing / 2.0
|
|
||||||
elif anchor[1] == "d":
|
|
||||||
top -= (len(lines) - 1) * line_spacing
|
|
||||||
|
|
||||||
idx = -1
|
|
||||||
for line in lines:
|
|
||||||
left = xy[0]
|
|
||||||
idx += 1
|
|
||||||
width_difference = max_width - widths[idx]
|
|
||||||
|
|
||||||
# align by align parameter
|
|
||||||
if align in ("left", "justify"):
|
|
||||||
pass
|
|
||||||
elif align == "center":
|
|
||||||
left += width_difference / 2.0
|
|
||||||
elif align == "right":
|
|
||||||
left += width_difference
|
|
||||||
else:
|
|
||||||
msg = 'align must be "left", "center", "right" or "justify"'
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
if (
|
|
||||||
align == "justify"
|
|
||||||
and width_difference != 0
|
|
||||||
and idx != len(lines) - 1
|
|
||||||
):
|
|
||||||
words = (
|
|
||||||
line.split(" ") if isinstance(line, str) else line.split(b" ")
|
|
||||||
)
|
|
||||||
if len(words) > 1:
|
|
||||||
# align left by anchor
|
|
||||||
if anchor[0] == "m":
|
|
||||||
left -= max_width / 2.0
|
|
||||||
elif anchor[0] == "r":
|
|
||||||
left -= max_width
|
|
||||||
|
|
||||||
word_widths = [
|
|
||||||
self.font.getlength(
|
|
||||||
word,
|
|
||||||
fontmode,
|
|
||||||
self.direction,
|
|
||||||
self.features,
|
|
||||||
self.language,
|
|
||||||
)
|
|
||||||
for word in words
|
|
||||||
]
|
|
||||||
word_anchor = "l" + anchor[1]
|
|
||||||
width_difference = max_width - sum(word_widths)
|
|
||||||
i = 0
|
|
||||||
for word in words:
|
|
||||||
parts.append(_Line(left, top, word_anchor, word))
|
|
||||||
left += word_widths[i] + width_difference / (len(words) - 1)
|
|
||||||
i += 1
|
|
||||||
top += line_spacing
|
|
||||||
continue
|
|
||||||
|
|
||||||
# align left by anchor
|
|
||||||
if anchor[0] == "m":
|
|
||||||
left -= width_difference / 2.0
|
|
||||||
elif anchor[0] == "r":
|
|
||||||
left -= width_difference
|
|
||||||
parts.append(_Line(left, top, anchor, line))
|
|
||||||
top += line_spacing
|
|
||||||
|
|
||||||
return parts
|
|
||||||
|
|
||||||
def _get_bbox(
|
|
||||||
self, text: str | bytes, font: Font | None = None, anchor: str | None = None
|
|
||||||
) -> tuple[float, float, float, float]:
|
|
||||||
return (font or self.font).getbbox(
|
|
||||||
text,
|
|
||||||
self._get_fontmode(),
|
|
||||||
self.direction,
|
|
||||||
self.features,
|
|
||||||
self.language,
|
|
||||||
self.stroke_width,
|
|
||||||
anchor,
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_bbox(
|
|
||||||
self,
|
|
||||||
xy: tuple[float, float] = (0, 0),
|
|
||||||
anchor: str | None = None,
|
|
||||||
align: str = "left",
|
|
||||||
) -> tuple[float, float, float, float]:
|
|
||||||
"""
|
|
||||||
Returns bounding box (in pixels) of text.
|
|
||||||
|
|
||||||
Use :py:meth:`get_length` to get the offset of following text with 1/64 pixel
|
|
||||||
precision. The bounding box includes extra margins for some fonts, e.g. italics
|
|
||||||
or accents.
|
|
||||||
|
|
||||||
:param xy: The anchor coordinates of the text.
|
|
||||||
:param anchor: The text anchor alignment. Determines the relative location of
|
|
||||||
the anchor to the text. The default alignment is top left,
|
|
||||||
specifically ``la`` for horizontal text and ``lt`` for
|
|
||||||
vertical text. See :ref:`text-anchors` for details.
|
|
||||||
:param align: For multiline text, ``"left"``, ``"center"``, ``"right"`` or
|
|
||||||
``"justify"`` determines the relative alignment of lines. Use the
|
|
||||||
``anchor`` parameter to specify the alignment to ``xy``.
|
|
||||||
|
|
||||||
:return: ``(left, top, right, bottom)`` bounding box
|
|
||||||
"""
|
|
||||||
bbox: tuple[float, float, float, float] | None = None
|
|
||||||
for x, y, anchor, text in self._split(xy, anchor, align):
|
|
||||||
bbox_line = self._get_bbox(text, anchor=anchor)
|
|
||||||
bbox_line = (
|
|
||||||
bbox_line[0] + x,
|
|
||||||
bbox_line[1] + y,
|
|
||||||
bbox_line[2] + x,
|
|
||||||
bbox_line[3] + y,
|
|
||||||
)
|
|
||||||
if bbox is None:
|
|
||||||
bbox = bbox_line
|
|
||||||
else:
|
|
||||||
bbox = (
|
|
||||||
min(bbox[0], bbox_line[0]),
|
|
||||||
min(bbox[1], bbox_line[1]),
|
|
||||||
max(bbox[2], bbox_line[2]),
|
|
||||||
max(bbox[3], bbox_line[3]),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert bbox is not None
|
|
||||||
return bbox
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# a Tk display interface
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 96-04-08 fl Created
|
|
||||||
# 96-09-06 fl Added getimage method
|
|
||||||
# 96-11-01 fl Rewritten, removed image attribute and crop method
|
|
||||||
# 97-05-09 fl Use PyImagingPaste method instead of image type
|
|
||||||
# 97-05-12 fl Minor tweaks to match the IFUNC95 interface
|
|
||||||
# 97-05-17 fl Support the "pilbitmap" booster patch
|
|
||||||
# 97-06-05 fl Added file= and data= argument to image constructors
|
|
||||||
# 98-03-09 fl Added width and height methods to Image classes
|
|
||||||
# 98-07-02 fl Use default mode for "P" images without palette attribute
|
|
||||||
# 98-07-02 fl Explicitly destroy Tkinter image objects
|
|
||||||
# 99-07-24 fl Support multiple Tk interpreters (from Greg Couch)
|
|
||||||
# 99-07-26 fl Automatically hook into Tkinter (if possible)
|
|
||||||
# 99-08-15 fl Hook uses _imagingtk instead of _imaging
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-1999 by Secret Labs AB
|
|
||||||
# Copyright (c) 1996-1997 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tkinter
|
|
||||||
from io import BytesIO
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ._typing import CapsuleType
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Check for Tkinter interface hooks
|
|
||||||
|
|
||||||
|
|
||||||
def _get_image_from_kw(kw: dict[str, Any]) -> ImageFile.ImageFile | None:
|
|
||||||
source = None
|
|
||||||
if "file" in kw:
|
|
||||||
source = kw.pop("file")
|
|
||||||
elif "data" in kw:
|
|
||||||
source = BytesIO(kw.pop("data"))
|
|
||||||
if not source:
|
|
||||||
return None
|
|
||||||
return Image.open(source)
|
|
||||||
|
|
||||||
|
|
||||||
def _pyimagingtkcall(
|
|
||||||
command: str, photo: PhotoImage | tkinter.PhotoImage, ptr: CapsuleType
|
|
||||||
) -> None:
|
|
||||||
tk = photo.tk
|
|
||||||
try:
|
|
||||||
tk.call(command, photo, repr(ptr))
|
|
||||||
except tkinter.TclError:
|
|
||||||
# activate Tkinter hook
|
|
||||||
# may raise an error if it cannot attach to Tkinter
|
|
||||||
from . import _imagingtk
|
|
||||||
|
|
||||||
_imagingtk.tkinit(tk.interpaddr())
|
|
||||||
tk.call(command, photo, repr(ptr))
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# PhotoImage
|
|
||||||
|
|
||||||
|
|
||||||
class PhotoImage:
|
|
||||||
"""
|
|
||||||
A Tkinter-compatible photo image. This can be used
|
|
||||||
everywhere Tkinter expects an image object. If the image is an RGBA
|
|
||||||
image, pixels having alpha 0 are treated as transparent.
|
|
||||||
|
|
||||||
The constructor takes either a PIL image, or a mode and a size.
|
|
||||||
Alternatively, you can use the ``file`` or ``data`` options to initialize
|
|
||||||
the photo image object.
|
|
||||||
|
|
||||||
:param image: Either a PIL image, or a mode string. If a mode string is
|
|
||||||
used, a size must also be given.
|
|
||||||
:param size: If the first argument is a mode string, this defines the size
|
|
||||||
of the image.
|
|
||||||
:keyword file: A filename to load the image from (using
|
|
||||||
``Image.open(file)``).
|
|
||||||
:keyword data: An 8-bit string containing image data (as loaded from an
|
|
||||||
image file).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
image: Image.Image | str | None = None,
|
|
||||||
size: tuple[int, int] | None = None,
|
|
||||||
**kw: Any,
|
|
||||||
) -> None:
|
|
||||||
# Tk compatibility: file or data
|
|
||||||
if image is None:
|
|
||||||
image = _get_image_from_kw(kw)
|
|
||||||
|
|
||||||
if image is None:
|
|
||||||
msg = "Image is required"
|
|
||||||
raise ValueError(msg)
|
|
||||||
elif isinstance(image, str):
|
|
||||||
mode = image
|
|
||||||
image = None
|
|
||||||
|
|
||||||
if size is None:
|
|
||||||
msg = "If first argument is mode, size is required"
|
|
||||||
raise ValueError(msg)
|
|
||||||
else:
|
|
||||||
# got an image instead of a mode
|
|
||||||
mode = image.mode
|
|
||||||
if mode == "P":
|
|
||||||
# palette mapped data
|
|
||||||
image.apply_transparency()
|
|
||||||
image.load()
|
|
||||||
mode = image.palette.mode if image.palette else "RGB"
|
|
||||||
size = image.size
|
|
||||||
kw["width"], kw["height"] = size
|
|
||||||
|
|
||||||
if mode not in ["1", "L", "RGB", "RGBA"]:
|
|
||||||
mode = Image.getmodebase(mode)
|
|
||||||
|
|
||||||
self.__mode = mode
|
|
||||||
self.__size = size
|
|
||||||
self.__photo = tkinter.PhotoImage(**kw)
|
|
||||||
self.tk = self.__photo.tk
|
|
||||||
if image:
|
|
||||||
self.paste(image)
|
|
||||||
|
|
||||||
def __del__(self) -> None:
|
|
||||||
try:
|
|
||||||
name = self.__photo.name
|
|
||||||
except AttributeError:
|
|
||||||
return
|
|
||||||
self.__photo.name = None
|
|
||||||
try:
|
|
||||||
self.__photo.tk.call("image", "delete", name)
|
|
||||||
except Exception:
|
|
||||||
pass # ignore internal errors
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""
|
|
||||||
Get the Tkinter photo image identifier. This method is automatically
|
|
||||||
called by Tkinter whenever a PhotoImage object is passed to a Tkinter
|
|
||||||
method.
|
|
||||||
|
|
||||||
:return: A Tkinter photo image identifier (a string).
|
|
||||||
"""
|
|
||||||
return str(self.__photo)
|
|
||||||
|
|
||||||
def width(self) -> int:
|
|
||||||
"""
|
|
||||||
Get the width of the image.
|
|
||||||
|
|
||||||
:return: The width, in pixels.
|
|
||||||
"""
|
|
||||||
return self.__size[0]
|
|
||||||
|
|
||||||
def height(self) -> int:
|
|
||||||
"""
|
|
||||||
Get the height of the image.
|
|
||||||
|
|
||||||
:return: The height, in pixels.
|
|
||||||
"""
|
|
||||||
return self.__size[1]
|
|
||||||
|
|
||||||
def paste(self, im: Image.Image) -> None:
|
|
||||||
"""
|
|
||||||
Paste a PIL image into the photo image. Note that this can
|
|
||||||
be very slow if the photo image is displayed.
|
|
||||||
|
|
||||||
:param im: A PIL image. The size must match the target region. If the
|
|
||||||
mode does not match, the image is converted to the mode of
|
|
||||||
the bitmap image.
|
|
||||||
"""
|
|
||||||
# convert to blittable
|
|
||||||
ptr = im.getim()
|
|
||||||
image = im.im
|
|
||||||
if not image.isblock() or im.mode != self.__mode:
|
|
||||||
block = Image.core.new_block(self.__mode, im.size)
|
|
||||||
image.convert2(block, image) # convert directly between buffers
|
|
||||||
ptr = block.ptr
|
|
||||||
|
|
||||||
_pyimagingtkcall("PyImagingPhoto", self.__photo, ptr)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# BitmapImage
|
|
||||||
|
|
||||||
|
|
||||||
class BitmapImage:
|
|
||||||
"""
|
|
||||||
A Tkinter-compatible bitmap image. This can be used everywhere Tkinter
|
|
||||||
expects an image object.
|
|
||||||
|
|
||||||
The given image must have mode "1". Pixels having value 0 are treated as
|
|
||||||
transparent. Options, if any, are passed on to Tkinter. The most commonly
|
|
||||||
used option is ``foreground``, which is used to specify the color for the
|
|
||||||
non-transparent parts. See the Tkinter documentation for information on
|
|
||||||
how to specify colours.
|
|
||||||
|
|
||||||
:param image: A PIL image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, image: Image.Image | None = None, **kw: Any) -> None:
|
|
||||||
# Tk compatibility: file or data
|
|
||||||
if image is None:
|
|
||||||
image = _get_image_from_kw(kw)
|
|
||||||
|
|
||||||
if image is None:
|
|
||||||
msg = "Image is required"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.__mode = image.mode
|
|
||||||
self.__size = image.size
|
|
||||||
|
|
||||||
self.__photo = tkinter.BitmapImage(data=image.tobitmap(), **kw)
|
|
||||||
|
|
||||||
def __del__(self) -> None:
|
|
||||||
try:
|
|
||||||
name = self.__photo.name
|
|
||||||
except AttributeError:
|
|
||||||
return
|
|
||||||
self.__photo.name = None
|
|
||||||
try:
|
|
||||||
self.__photo.tk.call("image", "delete", name)
|
|
||||||
except Exception:
|
|
||||||
pass # ignore internal errors
|
|
||||||
|
|
||||||
def width(self) -> int:
|
|
||||||
"""
|
|
||||||
Get the width of the image.
|
|
||||||
|
|
||||||
:return: The width, in pixels.
|
|
||||||
"""
|
|
||||||
return self.__size[0]
|
|
||||||
|
|
||||||
def height(self) -> int:
|
|
||||||
"""
|
|
||||||
Get the height of the image.
|
|
||||||
|
|
||||||
:return: The height, in pixels.
|
|
||||||
"""
|
|
||||||
return self.__size[1]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
"""
|
|
||||||
Get the Tkinter bitmap image identifier. This method is automatically
|
|
||||||
called by Tkinter whenever a BitmapImage object is passed to a Tkinter
|
|
||||||
method.
|
|
||||||
|
|
||||||
:return: A Tkinter bitmap image identifier (a string).
|
|
||||||
"""
|
|
||||||
return str(self.__photo)
|
|
||||||
|
|
||||||
|
|
||||||
def getimage(photo: PhotoImage) -> Image.Image:
|
|
||||||
"""Copies the contents of a PhotoImage to a PIL image memory."""
|
|
||||||
im = Image.new("RGBA", (photo.width(), photo.height()))
|
|
||||||
|
|
||||||
_pyimagingtkcall("PyImagingPhotoGet", photo, im.getim())
|
|
||||||
|
|
||||||
return im
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# transform wrappers
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2002-04-08 fl Created
|
|
||||||
#
|
|
||||||
# Copyright (c) 2002 by Secret Labs AB
|
|
||||||
# Copyright (c) 2002 by Fredrik Lundh
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Sequence
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
|
|
||||||
class Transform(Image.ImageTransformHandler):
|
|
||||||
"""Base class for other transforms defined in :py:mod:`~PIL.ImageTransform`."""
|
|
||||||
|
|
||||||
method: Image.Transform
|
|
||||||
|
|
||||||
def __init__(self, data: Sequence[Any]) -> None:
|
|
||||||
self.data = data
|
|
||||||
|
|
||||||
def getdata(self) -> tuple[Image.Transform, Sequence[int]]:
|
|
||||||
return self.method, self.data
|
|
||||||
|
|
||||||
def transform(
|
|
||||||
self,
|
|
||||||
size: tuple[int, int],
|
|
||||||
image: Image.Image,
|
|
||||||
**options: Any,
|
|
||||||
) -> Image.Image:
|
|
||||||
"""Perform the transform. Called from :py:meth:`.Image.transform`."""
|
|
||||||
# can be overridden
|
|
||||||
method, data = self.getdata()
|
|
||||||
return image.transform(size, method, data, **options)
|
|
||||||
|
|
||||||
|
|
||||||
class AffineTransform(Transform):
|
|
||||||
"""
|
|
||||||
Define an affine image transform.
|
|
||||||
|
|
||||||
This function takes a 6-tuple (a, b, c, d, e, f) which contain the first
|
|
||||||
two rows from the inverse of an affine transform matrix. For each pixel
|
|
||||||
(x, y) in the output image, the new value is taken from a position (a x +
|
|
||||||
b y + c, d x + e y + f) in the input image, rounded to nearest pixel.
|
|
||||||
|
|
||||||
This function can be used to scale, translate, rotate, and shear the
|
|
||||||
original image.
|
|
||||||
|
|
||||||
See :py:meth:`.Image.transform`
|
|
||||||
|
|
||||||
:param matrix: A 6-tuple (a, b, c, d, e, f) containing the first two rows
|
|
||||||
from the inverse of an affine transform matrix.
|
|
||||||
"""
|
|
||||||
|
|
||||||
method = Image.Transform.AFFINE
|
|
||||||
|
|
||||||
|
|
||||||
class PerspectiveTransform(Transform):
|
|
||||||
"""
|
|
||||||
Define a perspective image transform.
|
|
||||||
|
|
||||||
This function takes an 8-tuple (a, b, c, d, e, f, g, h). For each pixel
|
|
||||||
(x, y) in the output image, the new value is taken from a position
|
|
||||||
((a x + b y + c) / (g x + h y + 1), (d x + e y + f) / (g x + h y + 1)) in
|
|
||||||
the input image, rounded to nearest pixel.
|
|
||||||
|
|
||||||
This function can be used to scale, translate, rotate, and shear the
|
|
||||||
original image.
|
|
||||||
|
|
||||||
See :py:meth:`.Image.transform`
|
|
||||||
|
|
||||||
:param matrix: An 8-tuple (a, b, c, d, e, f, g, h).
|
|
||||||
"""
|
|
||||||
|
|
||||||
method = Image.Transform.PERSPECTIVE
|
|
||||||
|
|
||||||
|
|
||||||
class ExtentTransform(Transform):
|
|
||||||
"""
|
|
||||||
Define a transform to extract a subregion from an image.
|
|
||||||
|
|
||||||
Maps a rectangle (defined by two corners) from the image to a rectangle of
|
|
||||||
the given size. The resulting image will contain data sampled from between
|
|
||||||
the corners, such that (x0, y0) in the input image will end up at (0,0) in
|
|
||||||
the output image, and (x1, y1) at size.
|
|
||||||
|
|
||||||
This method can be used to crop, stretch, shrink, or mirror an arbitrary
|
|
||||||
rectangle in the current image. It is slightly slower than crop, but about
|
|
||||||
as fast as a corresponding resize operation.
|
|
||||||
|
|
||||||
See :py:meth:`.Image.transform`
|
|
||||||
|
|
||||||
:param bbox: A 4-tuple (x0, y0, x1, y1) which specifies two points in the
|
|
||||||
input image's coordinate system. See :ref:`coordinate-system`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
method = Image.Transform.EXTENT
|
|
||||||
|
|
||||||
|
|
||||||
class QuadTransform(Transform):
|
|
||||||
"""
|
|
||||||
Define a quad image transform.
|
|
||||||
|
|
||||||
Maps a quadrilateral (a region defined by four corners) from the image to a
|
|
||||||
rectangle of the given size.
|
|
||||||
|
|
||||||
See :py:meth:`.Image.transform`
|
|
||||||
|
|
||||||
:param xy: An 8-tuple (x0, y0, x1, y1, x2, y2, x3, y3) which contain the
|
|
||||||
upper left, lower left, lower right, and upper right corner of the
|
|
||||||
source quadrilateral.
|
|
||||||
"""
|
|
||||||
|
|
||||||
method = Image.Transform.QUAD
|
|
||||||
|
|
||||||
|
|
||||||
class MeshTransform(Transform):
|
|
||||||
"""
|
|
||||||
Define a mesh image transform. A mesh transform consists of one or more
|
|
||||||
individual quad transforms.
|
|
||||||
|
|
||||||
See :py:meth:`.Image.transform`
|
|
||||||
|
|
||||||
:param data: A list of (bbox, quad) tuples.
|
|
||||||
"""
|
|
||||||
|
|
||||||
method = Image.Transform.MESH
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# a Windows DIB display interface
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1996-05-20 fl Created
|
|
||||||
# 1996-09-20 fl Fixed subregion exposure
|
|
||||||
# 1997-09-21 fl Added draw primitive (for tzPrint)
|
|
||||||
# 2003-05-21 fl Added experimental Window/ImageWindow classes
|
|
||||||
# 2003-09-05 fl Added fromstring/tostring methods
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997-2003.
|
|
||||||
# Copyright (c) Fredrik Lundh 1996-2003.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from . import Image
|
|
||||||
|
|
||||||
|
|
||||||
class HDC:
|
|
||||||
"""
|
|
||||||
Wraps an HDC integer. The resulting object can be passed to the
|
|
||||||
:py:meth:`~PIL.ImageWin.Dib.draw` and :py:meth:`~PIL.ImageWin.Dib.expose`
|
|
||||||
methods.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, dc: int) -> None:
|
|
||||||
self.dc = dc
|
|
||||||
|
|
||||||
def __int__(self) -> int:
|
|
||||||
return self.dc
|
|
||||||
|
|
||||||
|
|
||||||
class HWND:
|
|
||||||
"""
|
|
||||||
Wraps an HWND integer. The resulting object can be passed to the
|
|
||||||
:py:meth:`~PIL.ImageWin.Dib.draw` and :py:meth:`~PIL.ImageWin.Dib.expose`
|
|
||||||
methods, instead of a DC.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, wnd: int) -> None:
|
|
||||||
self.wnd = wnd
|
|
||||||
|
|
||||||
def __int__(self) -> int:
|
|
||||||
return self.wnd
|
|
||||||
|
|
||||||
|
|
||||||
class Dib:
|
|
||||||
"""
|
|
||||||
A Windows bitmap with the given mode and size. The mode can be one of "1",
|
|
||||||
"L", "P", or "RGB".
|
|
||||||
|
|
||||||
If the display requires a palette, this constructor creates a suitable
|
|
||||||
palette and associates it with the image. For an "L" image, 128 graylevels
|
|
||||||
are allocated. For an "RGB" image, a 6x6x6 colour cube is used, together
|
|
||||||
with 20 graylevels.
|
|
||||||
|
|
||||||
To make sure that palettes work properly under Windows, you must call the
|
|
||||||
``palette`` method upon certain events from Windows.
|
|
||||||
|
|
||||||
:param image: Either a PIL image, or a mode string. If a mode string is
|
|
||||||
used, a size must also be given. The mode can be one of "1",
|
|
||||||
"L", "P", or "RGB".
|
|
||||||
:param size: If the first argument is a mode string, this
|
|
||||||
defines the size of the image.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, image: Image.Image | str, size: tuple[int, int] | None = None
|
|
||||||
) -> None:
|
|
||||||
if isinstance(image, str):
|
|
||||||
mode = image
|
|
||||||
image = ""
|
|
||||||
if size is None:
|
|
||||||
msg = "If first argument is mode, size is required"
|
|
||||||
raise ValueError(msg)
|
|
||||||
else:
|
|
||||||
mode = image.mode
|
|
||||||
size = image.size
|
|
||||||
if mode not in ["1", "L", "P", "RGB"]:
|
|
||||||
mode = Image.getmodebase(mode)
|
|
||||||
self.image = Image.core.display(mode, size)
|
|
||||||
self.mode = mode
|
|
||||||
self.size = size
|
|
||||||
if image:
|
|
||||||
assert not isinstance(image, str)
|
|
||||||
self.paste(image)
|
|
||||||
|
|
||||||
def expose(self, handle: int | HDC | HWND) -> None:
|
|
||||||
"""
|
|
||||||
Copy the bitmap contents to a device context.
|
|
||||||
|
|
||||||
:param handle: Device context (HDC), cast to a Python integer, or an
|
|
||||||
HDC or HWND instance. In PythonWin, you can use
|
|
||||||
``CDC.GetHandleAttrib()`` to get a suitable handle.
|
|
||||||
"""
|
|
||||||
handle_int = int(handle)
|
|
||||||
if isinstance(handle, HWND):
|
|
||||||
dc = self.image.getdc(handle_int)
|
|
||||||
try:
|
|
||||||
self.image.expose(dc)
|
|
||||||
finally:
|
|
||||||
self.image.releasedc(handle_int, dc)
|
|
||||||
else:
|
|
||||||
self.image.expose(handle_int)
|
|
||||||
|
|
||||||
def draw(
|
|
||||||
self,
|
|
||||||
handle: int | HDC | HWND,
|
|
||||||
dst: tuple[int, int, int, int],
|
|
||||||
src: tuple[int, int, int, int] | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Same as expose, but allows you to specify where to draw the image, and
|
|
||||||
what part of it to draw.
|
|
||||||
|
|
||||||
The destination and source areas are given as 4-tuple rectangles. If
|
|
||||||
the source is omitted, the entire image is copied. If the source and
|
|
||||||
the destination have different sizes, the image is resized as
|
|
||||||
necessary.
|
|
||||||
"""
|
|
||||||
if src is None:
|
|
||||||
src = (0, 0) + self.size
|
|
||||||
handle_int = int(handle)
|
|
||||||
if isinstance(handle, HWND):
|
|
||||||
dc = self.image.getdc(handle_int)
|
|
||||||
try:
|
|
||||||
self.image.draw(dc, dst, src)
|
|
||||||
finally:
|
|
||||||
self.image.releasedc(handle_int, dc)
|
|
||||||
else:
|
|
||||||
self.image.draw(handle_int, dst, src)
|
|
||||||
|
|
||||||
def query_palette(self, handle: int | HDC | HWND) -> int:
|
|
||||||
"""
|
|
||||||
Installs the palette associated with the image in the given device
|
|
||||||
context.
|
|
||||||
|
|
||||||
This method should be called upon **QUERYNEWPALETTE** and
|
|
||||||
**PALETTECHANGED** events from Windows. If this method returns a
|
|
||||||
non-zero value, one or more display palette entries were changed, and
|
|
||||||
the image should be redrawn.
|
|
||||||
|
|
||||||
:param handle: Device context (HDC), cast to a Python integer, or an
|
|
||||||
HDC or HWND instance.
|
|
||||||
:return: The number of entries that were changed (if one or more entries,
|
|
||||||
this indicates that the image should be redrawn).
|
|
||||||
"""
|
|
||||||
handle_int = int(handle)
|
|
||||||
if isinstance(handle, HWND):
|
|
||||||
handle = self.image.getdc(handle_int)
|
|
||||||
try:
|
|
||||||
result = self.image.query_palette(handle)
|
|
||||||
finally:
|
|
||||||
self.image.releasedc(handle, handle)
|
|
||||||
else:
|
|
||||||
result = self.image.query_palette(handle_int)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def paste(
|
|
||||||
self, im: Image.Image, box: tuple[int, int, int, int] | None = None
|
|
||||||
) -> None:
|
|
||||||
"""
|
|
||||||
Paste a PIL image into the bitmap image.
|
|
||||||
|
|
||||||
:param im: A PIL image. The size must match the target region.
|
|
||||||
If the mode does not match, the image is converted to the
|
|
||||||
mode of the bitmap image.
|
|
||||||
:param box: A 4-tuple defining the left, upper, right, and
|
|
||||||
lower pixel coordinate. See :ref:`coordinate-system`. If
|
|
||||||
None is given instead of a tuple, all of the image is
|
|
||||||
assumed.
|
|
||||||
"""
|
|
||||||
im.load()
|
|
||||||
if self.mode != im.mode:
|
|
||||||
im = im.convert(self.mode)
|
|
||||||
if box:
|
|
||||||
self.image.paste(im.im, box)
|
|
||||||
else:
|
|
||||||
self.image.paste(im.im)
|
|
||||||
|
|
||||||
def frombytes(self, buffer: bytes) -> None:
|
|
||||||
"""
|
|
||||||
Load display memory contents from byte data.
|
|
||||||
|
|
||||||
:param buffer: A buffer containing display data (usually
|
|
||||||
data returned from :py:func:`~PIL.ImageWin.Dib.tobytes`)
|
|
||||||
"""
|
|
||||||
self.image.frombytes(buffer)
|
|
||||||
|
|
||||||
def tobytes(self) -> bytes:
|
|
||||||
"""
|
|
||||||
Copy display memory contents to bytes object.
|
|
||||||
|
|
||||||
:return: A bytes object containing display data.
|
|
||||||
"""
|
|
||||||
return self.image.tobytes()
|
|
||||||
|
|
||||||
|
|
||||||
class Window:
|
|
||||||
"""Create a Window with the given title size."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, title: str = "PIL", width: int | None = None, height: int | None = None
|
|
||||||
) -> None:
|
|
||||||
self.hwnd = Image.core.createwindow(
|
|
||||||
title, self.__dispatcher, width or 0, height or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
def __dispatcher(self, action: str, *args: int) -> None:
|
|
||||||
getattr(self, f"ui_handle_{action}")(*args)
|
|
||||||
|
|
||||||
def ui_handle_clear(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def ui_handle_damage(self, x0: int, y0: int, x1: int, y1: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def ui_handle_destroy(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def ui_handle_resize(self, width: int, height: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def mainloop(self) -> None:
|
|
||||||
Image.core.eventloop()
|
|
||||||
|
|
||||||
|
|
||||||
class ImageWindow(Window):
|
|
||||||
"""Create an image window which displays the given image."""
|
|
||||||
|
|
||||||
def __init__(self, image: Image.Image | Dib, title: str = "PIL") -> None:
|
|
||||||
if not isinstance(image, Dib):
|
|
||||||
image = Dib(image)
|
|
||||||
self.image = image
|
|
||||||
width, height = image.size
|
|
||||||
super().__init__(title, width=width, height=height)
|
|
||||||
|
|
||||||
def ui_handle_repair(self, dc: int, x0: int, y0: int, x1: int, y1: int) -> None:
|
|
||||||
self.image.draw(dc, (x0, y0, x1, y1))
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# IM Tools support for PIL
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 1996-05-27 fl Created (read 8-bit images only)
|
|
||||||
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.2)
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997-2001.
|
|
||||||
# Copyright (c) Fredrik Lundh 1996-2001.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
field = re.compile(rb"([a-z]*) ([^ \r\n]*)")
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for IM Tools images.
|
|
||||||
|
|
||||||
|
|
||||||
class ImtImageFile(ImageFile.ImageFile):
|
|
||||||
format = "IMT"
|
|
||||||
format_description = "IM Tools"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
# Quick rejection: if there's not a LF among the first
|
|
||||||
# 100 bytes, this is (probably) not a text header.
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
|
|
||||||
buffer = self.fp.read(100)
|
|
||||||
if b"\n" not in buffer:
|
|
||||||
msg = "not an IM file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
xsize = ysize = 0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
if buffer:
|
|
||||||
s = buffer[:1]
|
|
||||||
buffer = buffer[1:]
|
|
||||||
else:
|
|
||||||
s = self.fp.read(1)
|
|
||||||
if not s:
|
|
||||||
break
|
|
||||||
|
|
||||||
if s == b"\x0c":
|
|
||||||
# image data begins
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile(
|
|
||||||
"raw",
|
|
||||||
(0, 0) + self.size,
|
|
||||||
self.fp.tell() - len(buffer),
|
|
||||||
self.mode,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
break
|
|
||||||
|
|
||||||
else:
|
|
||||||
# read key/value pair
|
|
||||||
if b"\n" not in buffer:
|
|
||||||
buffer += self.fp.read(100)
|
|
||||||
lines = buffer.split(b"\n")
|
|
||||||
s += lines.pop(0)
|
|
||||||
buffer = b"\n".join(lines)
|
|
||||||
if len(s) == 1 or len(s) > 100:
|
|
||||||
break
|
|
||||||
if s[0] == ord(b"*"):
|
|
||||||
continue # comment
|
|
||||||
|
|
||||||
m = field.match(s)
|
|
||||||
if not m:
|
|
||||||
break
|
|
||||||
k, v = m.group(1, 2)
|
|
||||||
if k == b"width":
|
|
||||||
xsize = int(v)
|
|
||||||
self._size = xsize, ysize
|
|
||||||
elif k == b"height":
|
|
||||||
ysize = int(v)
|
|
||||||
self._size = xsize, ysize
|
|
||||||
elif k == b"pixel" and v == b"n8":
|
|
||||||
self._mode = "L"
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
Image.register_open(ImtImageFile.format, ImtImageFile)
|
|
||||||
|
|
||||||
#
|
|
||||||
# no extension registered (".im" is simply too common)
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# IPTC/NAA file handling
|
|
||||||
#
|
|
||||||
# history:
|
|
||||||
# 1995-10-01 fl Created
|
|
||||||
# 1998-03-09 fl Cleaned up and added to PIL
|
|
||||||
# 2002-06-18 fl Added getiptcinfo helper
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997-2002.
|
|
||||||
# Copyright (c) Fredrik Lundh 1995.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from io import BytesIO
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
from ._binary import i16be as i16
|
|
||||||
from ._binary import i32be as i32
|
|
||||||
|
|
||||||
COMPRESSION = {1: "raw", 5: "jpeg"}
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# Helpers
|
|
||||||
|
|
||||||
|
|
||||||
def _i(c: bytes) -> int:
|
|
||||||
return i32((b"\0\0\0\0" + c)[-4:])
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for IPTC/NAA datastreams. To read IPTC/NAA fields
|
|
||||||
# from TIFF and JPEG files, use the <b>getiptcinfo</b> function.
|
|
||||||
|
|
||||||
|
|
||||||
class IptcImageFile(ImageFile.ImageFile):
|
|
||||||
format = "IPTC"
|
|
||||||
format_description = "IPTC/NAA"
|
|
||||||
|
|
||||||
def getint(self, key: tuple[int, int]) -> int:
|
|
||||||
return _i(self.info[key])
|
|
||||||
|
|
||||||
def field(self) -> tuple[tuple[int, int] | None, int]:
|
|
||||||
#
|
|
||||||
# get a IPTC field header
|
|
||||||
assert self.fp is not None
|
|
||||||
s = self.fp.read(5)
|
|
||||||
if not s.strip(b"\x00"):
|
|
||||||
return None, 0
|
|
||||||
|
|
||||||
tag = s[1], s[2]
|
|
||||||
|
|
||||||
# syntax
|
|
||||||
if s[0] != 0x1C or tag[0] not in [1, 2, 3, 4, 5, 6, 7, 8, 9, 240]:
|
|
||||||
msg = "invalid IPTC/NAA file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
# field size
|
|
||||||
size = s[3]
|
|
||||||
if size > 132:
|
|
||||||
msg = "illegal field length in IPTC/NAA file"
|
|
||||||
raise OSError(msg)
|
|
||||||
elif size == 128:
|
|
||||||
size = 0
|
|
||||||
elif size > 128:
|
|
||||||
size = _i(self.fp.read(size - 128))
|
|
||||||
else:
|
|
||||||
size = i16(s, 3)
|
|
||||||
|
|
||||||
return tag, size
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
# load descriptive fields
|
|
||||||
assert self.fp is not None
|
|
||||||
while True:
|
|
||||||
offset = self.fp.tell()
|
|
||||||
tag, size = self.field()
|
|
||||||
if not tag or tag == (8, 10):
|
|
||||||
break
|
|
||||||
if size:
|
|
||||||
tagdata = self.fp.read(size)
|
|
||||||
else:
|
|
||||||
tagdata = None
|
|
||||||
if tag in self.info:
|
|
||||||
if isinstance(self.info[tag], list):
|
|
||||||
self.info[tag].append(tagdata)
|
|
||||||
else:
|
|
||||||
self.info[tag] = [self.info[tag], tagdata]
|
|
||||||
else:
|
|
||||||
self.info[tag] = tagdata
|
|
||||||
|
|
||||||
# mode
|
|
||||||
layers = self.info[(3, 60)][0]
|
|
||||||
component = self.info[(3, 60)][1]
|
|
||||||
if layers == 1 and not component:
|
|
||||||
self._mode = "L"
|
|
||||||
band = None
|
|
||||||
else:
|
|
||||||
if layers == 3 and component:
|
|
||||||
self._mode = "RGB"
|
|
||||||
elif layers == 4 and component:
|
|
||||||
self._mode = "CMYK"
|
|
||||||
if (3, 65) in self.info:
|
|
||||||
band = self.info[(3, 65)][0] - 1
|
|
||||||
else:
|
|
||||||
band = 0
|
|
||||||
|
|
||||||
# size
|
|
||||||
self._size = self.getint((3, 20)), self.getint((3, 30))
|
|
||||||
|
|
||||||
# compression
|
|
||||||
try:
|
|
||||||
compression = COMPRESSION[self.getint((3, 120))]
|
|
||||||
except KeyError as e:
|
|
||||||
msg = "Unknown IPTC image compression"
|
|
||||||
raise OSError(msg) from e
|
|
||||||
|
|
||||||
# tile
|
|
||||||
if tag == (8, 10):
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile("iptc", (0, 0) + self.size, offset, (compression, band))
|
|
||||||
]
|
|
||||||
|
|
||||||
def load(self) -> Image.core.PixelAccess | None:
|
|
||||||
if self.tile:
|
|
||||||
args = self.tile[0].args
|
|
||||||
assert isinstance(args, tuple)
|
|
||||||
compression, band = args
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
self.fp.seek(self.tile[0].offset)
|
|
||||||
|
|
||||||
# Copy image data to temporary file
|
|
||||||
o = BytesIO()
|
|
||||||
if compression == "raw":
|
|
||||||
# To simplify access to the extracted file,
|
|
||||||
# prepend a PPM header
|
|
||||||
o.write(b"P5\n%d %d\n255\n" % self.size)
|
|
||||||
while True:
|
|
||||||
type, size = self.field()
|
|
||||||
if type != (8, 10):
|
|
||||||
break
|
|
||||||
while size > 0:
|
|
||||||
s = self.fp.read(min(size, 8192))
|
|
||||||
if not s:
|
|
||||||
break
|
|
||||||
o.write(s)
|
|
||||||
size -= len(s)
|
|
||||||
|
|
||||||
with Image.open(o) as _im:
|
|
||||||
if band is not None:
|
|
||||||
bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode)
|
|
||||||
bands[band] = _im
|
|
||||||
im = Image.merge(self.mode, bands)
|
|
||||||
else:
|
|
||||||
im = _im
|
|
||||||
im.load()
|
|
||||||
self.im = im.im
|
|
||||||
self.tile = []
|
|
||||||
return ImageFile.ImageFile.load(self)
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(IptcImageFile.format, IptcImageFile)
|
|
||||||
|
|
||||||
Image.register_extension(IptcImageFile.format, ".iim")
|
|
||||||
|
|
||||||
|
|
||||||
def getiptcinfo(
|
|
||||||
im: ImageFile.ImageFile,
|
|
||||||
) -> dict[tuple[int, int], bytes | list[bytes]] | None:
|
|
||||||
"""
|
|
||||||
Get IPTC information from TIFF, JPEG, or IPTC file.
|
|
||||||
|
|
||||||
:param im: An image containing IPTC data.
|
|
||||||
:returns: A dictionary containing IPTC information, or None if
|
|
||||||
no IPTC information block was found.
|
|
||||||
"""
|
|
||||||
from . import JpegImagePlugin, TiffImagePlugin
|
|
||||||
|
|
||||||
data = None
|
|
||||||
|
|
||||||
if isinstance(im, IptcImageFile):
|
|
||||||
# return info dictionary right away
|
|
||||||
return {k: v for k, v in im.info.items() if isinstance(k, tuple)}
|
|
||||||
|
|
||||||
elif isinstance(im, JpegImagePlugin.JpegImageFile):
|
|
||||||
# extract the IPTC/NAA resource
|
|
||||||
photoshop = im.info.get("photoshop")
|
|
||||||
if photoshop:
|
|
||||||
data = photoshop.get(0x0404)
|
|
||||||
|
|
||||||
elif isinstance(im, TiffImagePlugin.TiffImageFile):
|
|
||||||
# get raw data from the IPTC/NAA tag (PhotoShop tags the data
|
|
||||||
# as 4-byte integers, so we cannot use the get method...)
|
|
||||||
try:
|
|
||||||
data = im.tag_v2._tagdata[TiffImagePlugin.IPTC_NAA_CHUNK]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if data is None:
|
|
||||||
return None # no properties
|
|
||||||
|
|
||||||
# create an IptcImagePlugin object without initializing it
|
|
||||||
class FakeImage:
|
|
||||||
pass
|
|
||||||
|
|
||||||
fake_im = FakeImage()
|
|
||||||
fake_im.__class__ = IptcImageFile # type: ignore[assignment]
|
|
||||||
iptc_im = cast(IptcImageFile, fake_im)
|
|
||||||
|
|
||||||
# parse the IPTC information chunk
|
|
||||||
iptc_im.info = {}
|
|
||||||
iptc_im.fp = BytesIO(data)
|
|
||||||
|
|
||||||
try:
|
|
||||||
iptc_im._open()
|
|
||||||
except (IndexError, KeyError):
|
|
||||||
pass # expected failure
|
|
||||||
|
|
||||||
return {k: v for k, v in iptc_im.info.items() if isinstance(k, tuple)}
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# JPEG2000 file handling
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2014-03-12 ajh Created
|
|
||||||
# 2021-06-30 rogermb Extract dpi information from the 'resc' header box
|
|
||||||
#
|
|
||||||
# Copyright (c) 2014 Coriolis Systems Limited
|
|
||||||
# Copyright (c) 2014 Alastair Houghton
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from . import Image, ImageFile, ImagePalette, _binary
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
|
|
||||||
class BoxReader:
|
|
||||||
"""
|
|
||||||
A small helper class to read fields stored in JPEG2000 header boxes
|
|
||||||
and to easily step into and read sub-boxes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, fp: IO[bytes], length: int = -1) -> None:
|
|
||||||
self.fp = fp
|
|
||||||
self.has_length = length >= 0
|
|
||||||
self.length = length
|
|
||||||
self.remaining_in_box = -1
|
|
||||||
|
|
||||||
def _can_read(self, num_bytes: int) -> bool:
|
|
||||||
if self.has_length and self.fp.tell() + num_bytes > self.length:
|
|
||||||
# Outside box: ensure we don't read past the known file length
|
|
||||||
return False
|
|
||||||
if self.remaining_in_box >= 0:
|
|
||||||
# Inside box contents: ensure read does not go past box boundaries
|
|
||||||
return num_bytes <= self.remaining_in_box
|
|
||||||
else:
|
|
||||||
return True # No length known, just read
|
|
||||||
|
|
||||||
def _read_bytes(self, num_bytes: int) -> bytes:
|
|
||||||
if not self._can_read(num_bytes):
|
|
||||||
msg = "Not enough data in header"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
data = self.fp.read(num_bytes)
|
|
||||||
if len(data) < num_bytes:
|
|
||||||
msg = f"Expected to read {num_bytes} bytes but only got {len(data)}."
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
if self.remaining_in_box > 0:
|
|
||||||
self.remaining_in_box -= num_bytes
|
|
||||||
return data
|
|
||||||
|
|
||||||
def read_fields(self, field_format: str) -> tuple[int | bytes, ...]:
|
|
||||||
size = struct.calcsize(field_format)
|
|
||||||
data = self._read_bytes(size)
|
|
||||||
return struct.unpack(field_format, data)
|
|
||||||
|
|
||||||
def read_boxes(self) -> BoxReader:
|
|
||||||
size = self.remaining_in_box
|
|
||||||
data = self._read_bytes(size)
|
|
||||||
return BoxReader(io.BytesIO(data), size)
|
|
||||||
|
|
||||||
def has_next_box(self) -> bool:
|
|
||||||
if self.has_length:
|
|
||||||
return self.fp.tell() + self.remaining_in_box < self.length
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def next_box_type(self) -> bytes:
|
|
||||||
# Skip the rest of the box if it has not been read
|
|
||||||
if self.remaining_in_box > 0:
|
|
||||||
self.fp.seek(self.remaining_in_box, os.SEEK_CUR)
|
|
||||||
self.remaining_in_box = -1
|
|
||||||
|
|
||||||
# Read the length and type of the next box
|
|
||||||
lbox, tbox = cast(tuple[int, bytes], self.read_fields(">I4s"))
|
|
||||||
if lbox == 1:
|
|
||||||
lbox = cast(int, self.read_fields(">Q")[0])
|
|
||||||
hlen = 16
|
|
||||||
else:
|
|
||||||
hlen = 8
|
|
||||||
|
|
||||||
if lbox < hlen or not self._can_read(lbox - hlen):
|
|
||||||
msg = "Invalid header length"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self.remaining_in_box = lbox - hlen
|
|
||||||
return tbox
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_codestream(fp: IO[bytes]) -> tuple[tuple[int, int], str]:
|
|
||||||
"""Parse the JPEG 2000 codestream to extract the size and component
|
|
||||||
count from the SIZ marker segment, returning a PIL (size, mode) tuple."""
|
|
||||||
|
|
||||||
hdr = fp.read(2)
|
|
||||||
lsiz = _binary.i16be(hdr)
|
|
||||||
siz = hdr + fp.read(lsiz - 2)
|
|
||||||
lsiz, rsiz, xsiz, ysiz, xosiz, yosiz, _, _, _, _, csiz = struct.unpack_from(
|
|
||||||
">HHIIIIIIIIH", siz
|
|
||||||
)
|
|
||||||
|
|
||||||
size = (xsiz - xosiz, ysiz - yosiz)
|
|
||||||
if csiz == 1:
|
|
||||||
ssiz = struct.unpack_from(">B", siz, 38)
|
|
||||||
if (ssiz[0] & 0x7F) + 1 > 8:
|
|
||||||
mode = "I;16"
|
|
||||||
else:
|
|
||||||
mode = "L"
|
|
||||||
elif csiz == 2:
|
|
||||||
mode = "LA"
|
|
||||||
elif csiz == 3:
|
|
||||||
mode = "RGB"
|
|
||||||
elif csiz == 4:
|
|
||||||
mode = "RGBA"
|
|
||||||
else:
|
|
||||||
msg = "unable to determine J2K image mode"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
return size, mode
|
|
||||||
|
|
||||||
|
|
||||||
def _res_to_dpi(num: int, denom: int, exp: int) -> float | None:
|
|
||||||
"""Convert JPEG2000's (numerator, denominator, exponent-base-10) resolution,
|
|
||||||
calculated as (num / denom) * 10^exp and stored in dots per meter,
|
|
||||||
to floating-point dots per inch."""
|
|
||||||
if denom == 0:
|
|
||||||
return None
|
|
||||||
return (254 * num * (10**exp)) / (10000 * denom)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_jp2_header(
|
|
||||||
fp: IO[bytes],
|
|
||||||
) -> tuple[
|
|
||||||
tuple[int, int],
|
|
||||||
str,
|
|
||||||
str | None,
|
|
||||||
tuple[float, float] | None,
|
|
||||||
ImagePalette.ImagePalette | None,
|
|
||||||
]:
|
|
||||||
"""Parse the JP2 header box to extract size, component count,
|
|
||||||
color space information, and optionally DPI information,
|
|
||||||
returning a (size, mode, mimetype, dpi) tuple."""
|
|
||||||
|
|
||||||
# Find the JP2 header box
|
|
||||||
reader = BoxReader(fp)
|
|
||||||
header = None
|
|
||||||
mimetype = None
|
|
||||||
while reader.has_next_box():
|
|
||||||
tbox = reader.next_box_type()
|
|
||||||
|
|
||||||
if tbox == b"jp2h":
|
|
||||||
header = reader.read_boxes()
|
|
||||||
break
|
|
||||||
elif tbox == b"ftyp":
|
|
||||||
if reader.read_fields(">4s")[0] == b"jpx ":
|
|
||||||
mimetype = "image/jpx"
|
|
||||||
assert header is not None
|
|
||||||
|
|
||||||
size = None
|
|
||||||
mode = None
|
|
||||||
bpc = None
|
|
||||||
nc = None
|
|
||||||
dpi = None # 2-tuple of DPI info, or None
|
|
||||||
palette = None
|
|
||||||
colr = None
|
|
||||||
|
|
||||||
while header.has_next_box():
|
|
||||||
tbox = header.next_box_type()
|
|
||||||
|
|
||||||
if tbox == b"ihdr":
|
|
||||||
height, width, nc, bpc = header.read_fields(">IIHB")
|
|
||||||
assert isinstance(height, int)
|
|
||||||
assert isinstance(width, int)
|
|
||||||
assert isinstance(bpc, int)
|
|
||||||
size = (width, height)
|
|
||||||
if nc == 1 and (bpc & 0x7F) > 8:
|
|
||||||
mode = "I;16"
|
|
||||||
elif nc == 1:
|
|
||||||
mode = "L"
|
|
||||||
elif nc == 2:
|
|
||||||
mode = "LA"
|
|
||||||
elif nc == 3:
|
|
||||||
mode = "RGB"
|
|
||||||
elif nc == 4:
|
|
||||||
mode = "RGBA"
|
|
||||||
elif tbox == b"colr":
|
|
||||||
meth, _, _, enumcs = header.read_fields(">BBBI")
|
|
||||||
if meth == 1:
|
|
||||||
if enumcs in (0, 15):
|
|
||||||
colr = "1"
|
|
||||||
elif enumcs == 12:
|
|
||||||
colr = "CMYK"
|
|
||||||
if nc == 4:
|
|
||||||
mode = "CMYK"
|
|
||||||
elif enumcs == 17:
|
|
||||||
colr = "L"
|
|
||||||
elif tbox == b"pclr" and mode in ("L", "LA") and colr not in ("1", "L"):
|
|
||||||
ne, npc = header.read_fields(">HB")
|
|
||||||
assert isinstance(ne, int)
|
|
||||||
assert isinstance(npc, int)
|
|
||||||
max_bitdepth = 0
|
|
||||||
for bitdepth in header.read_fields(">" + ("B" * npc)):
|
|
||||||
assert isinstance(bitdepth, int)
|
|
||||||
if bitdepth > max_bitdepth:
|
|
||||||
max_bitdepth = bitdepth
|
|
||||||
if max_bitdepth <= 8:
|
|
||||||
if npc == 4:
|
|
||||||
palette_mode = "CMYK" if colr == "CMYK" else "RGBA"
|
|
||||||
else:
|
|
||||||
palette_mode = "RGB"
|
|
||||||
palette = ImagePalette.ImagePalette(palette_mode)
|
|
||||||
for i in range(ne):
|
|
||||||
color: list[int] = []
|
|
||||||
for value in header.read_fields(">" + ("B" * npc)):
|
|
||||||
assert isinstance(value, int)
|
|
||||||
color.append(value)
|
|
||||||
palette.getcolor(tuple(color))
|
|
||||||
mode = "P" if mode == "L" else "PA"
|
|
||||||
elif tbox == b"res ":
|
|
||||||
res = header.read_boxes()
|
|
||||||
while res.has_next_box():
|
|
||||||
tres = res.next_box_type()
|
|
||||||
if tres == b"resc":
|
|
||||||
vrcn, vrcd, hrcn, hrcd, vrce, hrce = res.read_fields(">HHHHBB")
|
|
||||||
assert isinstance(vrcn, int)
|
|
||||||
assert isinstance(vrcd, int)
|
|
||||||
assert isinstance(hrcn, int)
|
|
||||||
assert isinstance(hrcd, int)
|
|
||||||
assert isinstance(vrce, int)
|
|
||||||
assert isinstance(hrce, int)
|
|
||||||
hres = _res_to_dpi(hrcn, hrcd, hrce)
|
|
||||||
vres = _res_to_dpi(vrcn, vrcd, vrce)
|
|
||||||
if hres is not None and vres is not None:
|
|
||||||
dpi = (hres, vres)
|
|
||||||
break
|
|
||||||
|
|
||||||
if size is None or mode is None:
|
|
||||||
msg = "Malformed JP2 header"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
return size, mode, mimetype, dpi, palette
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for JPEG2000 images.
|
|
||||||
|
|
||||||
|
|
||||||
class Jpeg2KImageFile(ImageFile.ImageFile):
|
|
||||||
format = "JPEG2000"
|
|
||||||
format_description = "JPEG 2000 (ISO 15444)"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
sig = self.fp.read(4)
|
|
||||||
if sig == b"\xff\x4f\xff\x51":
|
|
||||||
self.codec = "j2k"
|
|
||||||
self._size, self._mode = _parse_codestream(self.fp)
|
|
||||||
self._parse_comment()
|
|
||||||
else:
|
|
||||||
sig = sig + self.fp.read(8)
|
|
||||||
|
|
||||||
if sig == b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a":
|
|
||||||
self.codec = "jp2"
|
|
||||||
header = _parse_jp2_header(self.fp)
|
|
||||||
self._size, self._mode, self.custom_mimetype, dpi, self.palette = header
|
|
||||||
if dpi is not None:
|
|
||||||
self.info["dpi"] = dpi
|
|
||||||
if self.fp.read(12).endswith(b"jp2c\xff\x4f\xff\x51"):
|
|
||||||
hdr = self.fp.read(2)
|
|
||||||
length = _binary.i16be(hdr)
|
|
||||||
self.fp.seek(length - 2, os.SEEK_CUR)
|
|
||||||
self._parse_comment()
|
|
||||||
else:
|
|
||||||
msg = "not a JPEG 2000 file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self._reduce = 0
|
|
||||||
self.layers = 0
|
|
||||||
|
|
||||||
fd = -1
|
|
||||||
length = -1
|
|
||||||
|
|
||||||
try:
|
|
||||||
fd = self.fp.fileno()
|
|
||||||
length = os.fstat(fd).st_size
|
|
||||||
except Exception:
|
|
||||||
fd = -1
|
|
||||||
try:
|
|
||||||
pos = self.fp.tell()
|
|
||||||
self.fp.seek(0, io.SEEK_END)
|
|
||||||
length = self.fp.tell()
|
|
||||||
self.fp.seek(pos)
|
|
||||||
except Exception:
|
|
||||||
length = -1
|
|
||||||
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile(
|
|
||||||
"jpeg2k",
|
|
||||||
(0, 0) + self.size,
|
|
||||||
0,
|
|
||||||
(self.codec, self._reduce, self.layers, fd, length),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def _parse_comment(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
while True:
|
|
||||||
marker = self.fp.read(2)
|
|
||||||
if not marker:
|
|
||||||
break
|
|
||||||
typ = marker[1]
|
|
||||||
if typ in (0x90, 0xD9):
|
|
||||||
# Start of tile or end of codestream
|
|
||||||
break
|
|
||||||
hdr = self.fp.read(2)
|
|
||||||
length = _binary.i16be(hdr)
|
|
||||||
if typ == 0x64:
|
|
||||||
# Comment
|
|
||||||
self.info["comment"] = self.fp.read(length - 2)[2:]
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
self.fp.seek(length - 2, os.SEEK_CUR)
|
|
||||||
|
|
||||||
@property # type: ignore[override]
|
|
||||||
def reduce(
|
|
||||||
self,
|
|
||||||
) -> (
|
|
||||||
Callable[[int | tuple[int, int], tuple[int, int, int, int] | None], Image.Image]
|
|
||||||
| int
|
|
||||||
):
|
|
||||||
# https://github.com/python-pillow/Pillow/issues/4343 found that the
|
|
||||||
# new Image 'reduce' method was shadowed by this plugin's 'reduce'
|
|
||||||
# property. This attempts to allow for both scenarios
|
|
||||||
return self._reduce or super().reduce
|
|
||||||
|
|
||||||
@reduce.setter
|
|
||||||
def reduce(self, value: int) -> None:
|
|
||||||
self._reduce = value
|
|
||||||
|
|
||||||
def load(self) -> Image.core.PixelAccess | None:
|
|
||||||
if self.tile and self._reduce:
|
|
||||||
power = 1 << self._reduce
|
|
||||||
adjust = power >> 1
|
|
||||||
self._size = (
|
|
||||||
int((self.size[0] + adjust) / power),
|
|
||||||
int((self.size[1] + adjust) / power),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update the reduce and layers settings
|
|
||||||
t = self.tile[0]
|
|
||||||
assert isinstance(t[3], tuple)
|
|
||||||
t3 = (t[3][0], self._reduce, self.layers, t[3][3], t[3][4])
|
|
||||||
self.tile = [ImageFile._Tile(t[0], (0, 0) + self.size, t[2], t3)]
|
|
||||||
|
|
||||||
return ImageFile.ImageFile.load(self)
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(
|
|
||||||
(b"\xff\x4f\xff\x51", b"\x00\x00\x00\x0cjP \x0d\x0a\x87\x0a")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Save support
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
# Get the keyword arguments
|
|
||||||
info = im.encoderinfo
|
|
||||||
|
|
||||||
if isinstance(filename, str):
|
|
||||||
filename = filename.encode()
|
|
||||||
if filename.endswith(b".j2k") or info.get("no_jp2", False):
|
|
||||||
kind = "j2k"
|
|
||||||
else:
|
|
||||||
kind = "jp2"
|
|
||||||
|
|
||||||
offset = info.get("offset", None)
|
|
||||||
tile_offset = info.get("tile_offset", None)
|
|
||||||
tile_size = info.get("tile_size", None)
|
|
||||||
quality_mode = info.get("quality_mode", "rates")
|
|
||||||
quality_layers = info.get("quality_layers", None)
|
|
||||||
if quality_layers is not None and not (
|
|
||||||
isinstance(quality_layers, (list, tuple))
|
|
||||||
and all(
|
|
||||||
isinstance(quality_layer, (int, float)) for quality_layer in quality_layers
|
|
||||||
)
|
|
||||||
):
|
|
||||||
msg = "quality_layers must be a sequence of numbers"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
num_resolutions = info.get("num_resolutions", 0)
|
|
||||||
cblk_size = info.get("codeblock_size", None)
|
|
||||||
precinct_size = info.get("precinct_size", None)
|
|
||||||
irreversible = info.get("irreversible", False)
|
|
||||||
progression = info.get("progression", "LRCP")
|
|
||||||
cinema_mode = info.get("cinema_mode", "no")
|
|
||||||
mct = info.get("mct", 0)
|
|
||||||
signed = info.get("signed", False)
|
|
||||||
comment = info.get("comment")
|
|
||||||
if isinstance(comment, str):
|
|
||||||
comment = comment.encode()
|
|
||||||
plt = info.get("plt", False)
|
|
||||||
|
|
||||||
fd = -1
|
|
||||||
if hasattr(fp, "fileno"):
|
|
||||||
try:
|
|
||||||
fd = fp.fileno()
|
|
||||||
except Exception:
|
|
||||||
fd = -1
|
|
||||||
|
|
||||||
im.encoderconfig = (
|
|
||||||
offset,
|
|
||||||
tile_offset,
|
|
||||||
tile_size,
|
|
||||||
quality_mode,
|
|
||||||
quality_layers,
|
|
||||||
num_resolutions,
|
|
||||||
cblk_size,
|
|
||||||
precinct_size,
|
|
||||||
irreversible,
|
|
||||||
progression,
|
|
||||||
cinema_mode,
|
|
||||||
mct,
|
|
||||||
signed,
|
|
||||||
fd,
|
|
||||||
comment,
|
|
||||||
plt,
|
|
||||||
)
|
|
||||||
|
|
||||||
ImageFile._save(im, fp, [ImageFile._Tile("jpeg2k", (0, 0) + im.size, 0, kind)])
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Registry stuff
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_open(Jpeg2KImageFile.format, Jpeg2KImageFile, _accept)
|
|
||||||
Image.register_save(Jpeg2KImageFile.format, _save)
|
|
||||||
|
|
||||||
Image.register_extensions(
|
|
||||||
Jpeg2KImageFile.format, [".jp2", ".j2k", ".jpc", ".jpf", ".jpx", ".j2c"]
|
|
||||||
)
|
|
||||||
|
|
||||||
Image.register_mime(Jpeg2KImageFile.format, "image/jp2")
|
|
||||||
@@ -1,889 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# JPEG (JFIF) file handling
|
|
||||||
#
|
|
||||||
# See "Digital Compression and Coding of Continuous-Tone Still Images,
|
|
||||||
# Part 1, Requirements and Guidelines" (CCITT T.81 / ISO 10918-1)
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1995-09-09 fl Created
|
|
||||||
# 1995-09-13 fl Added full parser
|
|
||||||
# 1996-03-25 fl Added hack to use the IJG command line utilities
|
|
||||||
# 1996-05-05 fl Workaround Photoshop 2.5 CMYK polarity bug
|
|
||||||
# 1996-05-28 fl Added draft support, JFIF version (0.1)
|
|
||||||
# 1996-12-30 fl Added encoder options, added progression property (0.2)
|
|
||||||
# 1997-08-27 fl Save mode 1 images as BW (0.3)
|
|
||||||
# 1998-07-12 fl Added YCbCr to draft and save methods (0.4)
|
|
||||||
# 1998-10-19 fl Don't hang on files using 16-bit DQT's (0.4.1)
|
|
||||||
# 2001-04-16 fl Extract DPI settings from JFIF files (0.4.2)
|
|
||||||
# 2002-07-01 fl Skip pad bytes before markers; identify Exif files (0.4.3)
|
|
||||||
# 2003-04-25 fl Added experimental EXIF decoder (0.5)
|
|
||||||
# 2003-06-06 fl Added experimental EXIF GPSinfo decoder
|
|
||||||
# 2003-09-13 fl Extract COM markers
|
|
||||||
# 2009-09-06 fl Added icc_profile support (from Florian Hoech)
|
|
||||||
# 2009-03-06 fl Changed CMYK handling; always use Adobe polarity (0.6)
|
|
||||||
# 2009-03-08 fl Added subsampling support (from Justin Huff).
|
|
||||||
#
|
|
||||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
|
||||||
# Copyright (c) 1995-1996 by Fredrik Lundh.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import array
|
|
||||||
import io
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
from ._binary import i16be as i16
|
|
||||||
from ._binary import i32be as i32
|
|
||||||
from ._binary import o8
|
|
||||||
from ._binary import o16be as o16
|
|
||||||
from .JpegPresets import presets
|
|
||||||
|
|
||||||
TYPE_CHECKING = False
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from typing import IO, Any
|
|
||||||
|
|
||||||
from .MpoImagePlugin import MpoImageFile
|
|
||||||
|
|
||||||
#
|
|
||||||
# Parser
|
|
||||||
|
|
||||||
|
|
||||||
def Skip(self: JpegImageFile, marker: int) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
n = i16(self.fp.read(2)) - 2
|
|
||||||
ImageFile._safe_read(self.fp, n)
|
|
||||||
|
|
||||||
|
|
||||||
def APP(self: JpegImageFile, marker: int) -> None:
|
|
||||||
#
|
|
||||||
# Application marker. Store these in the APP dictionary.
|
|
||||||
# Also look for well-known application markers.
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
n = i16(self.fp.read(2)) - 2
|
|
||||||
s = ImageFile._safe_read(self.fp, n)
|
|
||||||
|
|
||||||
app = f"APP{marker & 15}"
|
|
||||||
|
|
||||||
self.app[app] = s # compatibility
|
|
||||||
self.applist.append((app, s))
|
|
||||||
|
|
||||||
if marker == 0xFFE0 and s.startswith(b"JFIF"):
|
|
||||||
# extract JFIF information
|
|
||||||
self.info["jfif"] = version = i16(s, 5) # version
|
|
||||||
self.info["jfif_version"] = divmod(version, 256)
|
|
||||||
# extract JFIF properties
|
|
||||||
try:
|
|
||||||
jfif_unit = s[7]
|
|
||||||
jfif_density = i16(s, 8), i16(s, 10)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if jfif_unit == 1:
|
|
||||||
self.info["dpi"] = jfif_density
|
|
||||||
elif jfif_unit == 2: # cm
|
|
||||||
# 1 dpcm = 2.54 dpi
|
|
||||||
self.info["dpi"] = tuple(d * 2.54 for d in jfif_density)
|
|
||||||
self.info["jfif_unit"] = jfif_unit
|
|
||||||
self.info["jfif_density"] = jfif_density
|
|
||||||
elif marker == 0xFFE1 and s.startswith(b"Exif\0\0"):
|
|
||||||
# extract EXIF information
|
|
||||||
if "exif" in self.info:
|
|
||||||
self.info["exif"] += s[6:]
|
|
||||||
else:
|
|
||||||
self.info["exif"] = s
|
|
||||||
self._exif_offset = self.fp.tell() - n + 6
|
|
||||||
elif marker == 0xFFE1 and s.startswith(b"http://ns.adobe.com/xap/1.0/\x00"):
|
|
||||||
self.info["xmp"] = s.split(b"\x00", 1)[1]
|
|
||||||
elif marker == 0xFFE2 and s.startswith(b"FPXR\0"):
|
|
||||||
# extract FlashPix information (incomplete)
|
|
||||||
self.info["flashpix"] = s # FIXME: value will change
|
|
||||||
elif marker == 0xFFE2 and s.startswith(b"ICC_PROFILE\0"):
|
|
||||||
# Since an ICC profile can be larger than the maximum size of
|
|
||||||
# a JPEG marker (64K), we need provisions to split it into
|
|
||||||
# multiple markers. The format defined by the ICC specifies
|
|
||||||
# one or more APP2 markers containing the following data:
|
|
||||||
# Identifying string ASCII "ICC_PROFILE\0" (12 bytes)
|
|
||||||
# Marker sequence number 1, 2, etc (1 byte)
|
|
||||||
# Number of markers Total of APP2's used (1 byte)
|
|
||||||
# Profile data (remainder of APP2 data)
|
|
||||||
# Decoders should use the marker sequence numbers to
|
|
||||||
# reassemble the profile, rather than assuming that the APP2
|
|
||||||
# markers appear in the correct sequence.
|
|
||||||
self.icclist.append(s)
|
|
||||||
elif marker == 0xFFED and s.startswith(b"Photoshop 3.0\x00"):
|
|
||||||
# parse the image resource block
|
|
||||||
offset = 14
|
|
||||||
photoshop = self.info.setdefault("photoshop", {})
|
|
||||||
try:
|
|
||||||
while s[offset : offset + 4] == b"8BIM":
|
|
||||||
offset += 4
|
|
||||||
# resource code
|
|
||||||
code = i16(s, offset)
|
|
||||||
offset += 2
|
|
||||||
# resource name (usually empty)
|
|
||||||
name_len = s[offset]
|
|
||||||
# name = s[offset+1:offset+1+name_len]
|
|
||||||
offset += 1 + name_len
|
|
||||||
offset += offset & 1 # align
|
|
||||||
# resource data block
|
|
||||||
size = i32(s, offset)
|
|
||||||
offset += 4
|
|
||||||
data = s[offset : offset + size]
|
|
||||||
if code == 0x03ED: # ResolutionInfo
|
|
||||||
photoshop[code] = {
|
|
||||||
"XResolution": i32(data, 0) / 65536,
|
|
||||||
"DisplayedUnitsX": i16(data, 4),
|
|
||||||
"YResolution": i32(data, 8) / 65536,
|
|
||||||
"DisplayedUnitsY": i16(data, 12),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
photoshop[code] = data
|
|
||||||
offset += size
|
|
||||||
offset += offset & 1 # align
|
|
||||||
except struct.error:
|
|
||||||
pass # insufficient data
|
|
||||||
|
|
||||||
elif marker == 0xFFEE and s.startswith(b"Adobe"):
|
|
||||||
self.info["adobe"] = i16(s, 5)
|
|
||||||
# extract Adobe custom properties
|
|
||||||
try:
|
|
||||||
adobe_transform = s[11]
|
|
||||||
except IndexError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
self.info["adobe_transform"] = adobe_transform
|
|
||||||
elif marker == 0xFFE2 and s.startswith(b"MPF\0"):
|
|
||||||
# extract MPO information
|
|
||||||
self.info["mp"] = s[4:]
|
|
||||||
# offset is current location minus buffer size
|
|
||||||
# plus constant header size
|
|
||||||
self.info["mpoffset"] = self.fp.tell() - n + 4
|
|
||||||
|
|
||||||
|
|
||||||
def COM(self: JpegImageFile, marker: int) -> None:
|
|
||||||
#
|
|
||||||
# Comment marker. Store these in the APP dictionary.
|
|
||||||
assert self.fp is not None
|
|
||||||
n = i16(self.fp.read(2)) - 2
|
|
||||||
s = ImageFile._safe_read(self.fp, n)
|
|
||||||
|
|
||||||
self.info["comment"] = s
|
|
||||||
self.app["COM"] = s # compatibility
|
|
||||||
self.applist.append(("COM", s))
|
|
||||||
|
|
||||||
|
|
||||||
def SOF(self: JpegImageFile, marker: int) -> None:
|
|
||||||
#
|
|
||||||
# Start of frame marker. Defines the size and mode of the
|
|
||||||
# image. JPEG is colour blind, so we use some simple
|
|
||||||
# heuristics to map the number of layers to an appropriate
|
|
||||||
# mode. Note that this could be made a bit brighter, by
|
|
||||||
# looking for JFIF and Adobe APP markers.
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
n = i16(self.fp.read(2)) - 2
|
|
||||||
s = ImageFile._safe_read(self.fp, n)
|
|
||||||
self._size = i16(s, 3), i16(s, 1)
|
|
||||||
if self._im is not None and self.size != self.im.size:
|
|
||||||
self._im = None
|
|
||||||
|
|
||||||
self.bits = s[0]
|
|
||||||
if self.bits != 8:
|
|
||||||
msg = f"cannot handle {self.bits}-bit layers"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self.layers = s[5]
|
|
||||||
if self.layers == 1:
|
|
||||||
self._mode = "L"
|
|
||||||
elif self.layers == 3:
|
|
||||||
self._mode = "RGB"
|
|
||||||
elif self.layers == 4:
|
|
||||||
self._mode = "CMYK"
|
|
||||||
else:
|
|
||||||
msg = f"cannot handle {self.layers}-layer images"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
if marker in [0xFFC2, 0xFFC6, 0xFFCA, 0xFFCE]:
|
|
||||||
self.info["progressive"] = self.info["progression"] = 1
|
|
||||||
|
|
||||||
if self.icclist:
|
|
||||||
# fixup icc profile
|
|
||||||
self.icclist.sort() # sort by sequence number
|
|
||||||
if self.icclist[0][13] == len(self.icclist):
|
|
||||||
profile = [p[14:] for p in self.icclist]
|
|
||||||
icc_profile = b"".join(profile)
|
|
||||||
else:
|
|
||||||
icc_profile = None # wrong number of fragments
|
|
||||||
self.info["icc_profile"] = icc_profile
|
|
||||||
self.icclist = []
|
|
||||||
|
|
||||||
for i in range(6, len(s), 3):
|
|
||||||
t = s[i : i + 3]
|
|
||||||
# 4-tuples: id, vsamp, hsamp, qtable
|
|
||||||
self.layer.append((t[0], t[1] // 16, t[1] & 15, t[2]))
|
|
||||||
|
|
||||||
|
|
||||||
def DQT(self: JpegImageFile, marker: int) -> None:
|
|
||||||
#
|
|
||||||
# Define quantization table. Note that there might be more
|
|
||||||
# than one table in each marker.
|
|
||||||
|
|
||||||
# FIXME: The quantization tables can be used to estimate the
|
|
||||||
# compression quality.
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
n = i16(self.fp.read(2)) - 2
|
|
||||||
s = ImageFile._safe_read(self.fp, n)
|
|
||||||
while len(s):
|
|
||||||
v = s[0]
|
|
||||||
precision = 1 if (v // 16 == 0) else 2 # in bytes
|
|
||||||
qt_length = 1 + precision * 64
|
|
||||||
if len(s) < qt_length:
|
|
||||||
msg = "bad quantization table marker"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
data = array.array("B" if precision == 1 else "H", s[1:qt_length])
|
|
||||||
if sys.byteorder == "little" and precision > 1:
|
|
||||||
data.byteswap() # the values are always big-endian
|
|
||||||
self.quantization[v & 15] = [data[i] for i in zigzag_index]
|
|
||||||
s = s[qt_length:]
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# JPEG marker table
|
|
||||||
|
|
||||||
MARKER = {
|
|
||||||
0xFFC0: ("SOF0", "Baseline DCT", SOF),
|
|
||||||
0xFFC1: ("SOF1", "Extended Sequential DCT", SOF),
|
|
||||||
0xFFC2: ("SOF2", "Progressive DCT", SOF),
|
|
||||||
0xFFC3: ("SOF3", "Spatial lossless", SOF),
|
|
||||||
0xFFC4: ("DHT", "Define Huffman table", Skip),
|
|
||||||
0xFFC5: ("SOF5", "Differential sequential DCT", SOF),
|
|
||||||
0xFFC6: ("SOF6", "Differential progressive DCT", SOF),
|
|
||||||
0xFFC7: ("SOF7", "Differential spatial", SOF),
|
|
||||||
0xFFC8: ("JPG", "Extension", None),
|
|
||||||
0xFFC9: ("SOF9", "Extended sequential DCT (AC)", SOF),
|
|
||||||
0xFFCA: ("SOF10", "Progressive DCT (AC)", SOF),
|
|
||||||
0xFFCB: ("SOF11", "Spatial lossless DCT (AC)", SOF),
|
|
||||||
0xFFCC: ("DAC", "Define arithmetic coding conditioning", Skip),
|
|
||||||
0xFFCD: ("SOF13", "Differential sequential DCT (AC)", SOF),
|
|
||||||
0xFFCE: ("SOF14", "Differential progressive DCT (AC)", SOF),
|
|
||||||
0xFFCF: ("SOF15", "Differential spatial (AC)", SOF),
|
|
||||||
0xFFD0: ("RST0", "Restart 0", None),
|
|
||||||
0xFFD1: ("RST1", "Restart 1", None),
|
|
||||||
0xFFD2: ("RST2", "Restart 2", None),
|
|
||||||
0xFFD3: ("RST3", "Restart 3", None),
|
|
||||||
0xFFD4: ("RST4", "Restart 4", None),
|
|
||||||
0xFFD5: ("RST5", "Restart 5", None),
|
|
||||||
0xFFD6: ("RST6", "Restart 6", None),
|
|
||||||
0xFFD7: ("RST7", "Restart 7", None),
|
|
||||||
0xFFD8: ("SOI", "Start of image", None),
|
|
||||||
0xFFD9: ("EOI", "End of image", None),
|
|
||||||
0xFFDA: ("SOS", "Start of scan", Skip),
|
|
||||||
0xFFDB: ("DQT", "Define quantization table", DQT),
|
|
||||||
0xFFDC: ("DNL", "Define number of lines", Skip),
|
|
||||||
0xFFDD: ("DRI", "Define restart interval", Skip),
|
|
||||||
0xFFDE: ("DHP", "Define hierarchical progression", SOF),
|
|
||||||
0xFFDF: ("EXP", "Expand reference component", Skip),
|
|
||||||
0xFFE0: ("APP0", "Application segment 0", APP),
|
|
||||||
0xFFE1: ("APP1", "Application segment 1", APP),
|
|
||||||
0xFFE2: ("APP2", "Application segment 2", APP),
|
|
||||||
0xFFE3: ("APP3", "Application segment 3", APP),
|
|
||||||
0xFFE4: ("APP4", "Application segment 4", APP),
|
|
||||||
0xFFE5: ("APP5", "Application segment 5", APP),
|
|
||||||
0xFFE6: ("APP6", "Application segment 6", APP),
|
|
||||||
0xFFE7: ("APP7", "Application segment 7", APP),
|
|
||||||
0xFFE8: ("APP8", "Application segment 8", APP),
|
|
||||||
0xFFE9: ("APP9", "Application segment 9", APP),
|
|
||||||
0xFFEA: ("APP10", "Application segment 10", APP),
|
|
||||||
0xFFEB: ("APP11", "Application segment 11", APP),
|
|
||||||
0xFFEC: ("APP12", "Application segment 12", APP),
|
|
||||||
0xFFED: ("APP13", "Application segment 13", APP),
|
|
||||||
0xFFEE: ("APP14", "Application segment 14", APP),
|
|
||||||
0xFFEF: ("APP15", "Application segment 15", APP),
|
|
||||||
0xFFF0: ("JPG0", "Extension 0", None),
|
|
||||||
0xFFF1: ("JPG1", "Extension 1", None),
|
|
||||||
0xFFF2: ("JPG2", "Extension 2", None),
|
|
||||||
0xFFF3: ("JPG3", "Extension 3", None),
|
|
||||||
0xFFF4: ("JPG4", "Extension 4", None),
|
|
||||||
0xFFF5: ("JPG5", "Extension 5", None),
|
|
||||||
0xFFF6: ("JPG6", "Extension 6", None),
|
|
||||||
0xFFF7: ("JPG7", "Extension 7", None),
|
|
||||||
0xFFF8: ("JPG8", "Extension 8", None),
|
|
||||||
0xFFF9: ("JPG9", "Extension 9", None),
|
|
||||||
0xFFFA: ("JPG10", "Extension 10", None),
|
|
||||||
0xFFFB: ("JPG11", "Extension 11", None),
|
|
||||||
0xFFFC: ("JPG12", "Extension 12", None),
|
|
||||||
0xFFFD: ("JPG13", "Extension 13", None),
|
|
||||||
0xFFFE: ("COM", "Comment", COM),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
# Magic number was taken from https://en.wikipedia.org/wiki/JPEG
|
|
||||||
return prefix.startswith(b"\xff\xd8\xff")
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for JPEG and JFIF images.
|
|
||||||
|
|
||||||
|
|
||||||
class JpegImageFile(ImageFile.ImageFile):
|
|
||||||
format = "JPEG"
|
|
||||||
format_description = "JPEG (ISO 10918)"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
s = self.fp.read(3)
|
|
||||||
|
|
||||||
if not _accept(s):
|
|
||||||
msg = "not a JPEG file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
s = b"\xff"
|
|
||||||
|
|
||||||
# Create attributes
|
|
||||||
self.bits = self.layers = 0
|
|
||||||
self._exif_offset = 0
|
|
||||||
|
|
||||||
# JPEG specifics (internal)
|
|
||||||
self.layer: list[tuple[int, int, int, int]] = []
|
|
||||||
self._huffman_dc: dict[Any, Any] = {}
|
|
||||||
self._huffman_ac: dict[Any, Any] = {}
|
|
||||||
self.quantization: dict[int, list[int]] = {}
|
|
||||||
self.app: dict[str, bytes] = {} # compatibility
|
|
||||||
self.applist: list[tuple[str, bytes]] = []
|
|
||||||
self.icclist: list[bytes] = []
|
|
||||||
|
|
||||||
while True:
|
|
||||||
i = s[0]
|
|
||||||
if i == 0xFF:
|
|
||||||
s = s + self.fp.read(1)
|
|
||||||
i = i16(s)
|
|
||||||
else:
|
|
||||||
# Skip non-0xFF junk
|
|
||||||
s = self.fp.read(1)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if i in MARKER:
|
|
||||||
name, description, handler = MARKER[i]
|
|
||||||
if handler is not None:
|
|
||||||
handler(self, i)
|
|
||||||
if i == 0xFFDA: # start of scan
|
|
||||||
rawmode = self.mode
|
|
||||||
if self.mode == "CMYK":
|
|
||||||
rawmode = "CMYK;I" # assume adobe conventions
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile("jpeg", (0, 0) + self.size, 0, (rawmode, ""))
|
|
||||||
]
|
|
||||||
# self.__offset = self.fp.tell()
|
|
||||||
break
|
|
||||||
s = self.fp.read(1)
|
|
||||||
elif i in {0, 0xFFFF}:
|
|
||||||
# padded marker or junk; move on
|
|
||||||
s = b"\xff"
|
|
||||||
elif i == 0xFF00: # Skip extraneous data (escaped 0xFF)
|
|
||||||
s = self.fp.read(1)
|
|
||||||
else:
|
|
||||||
msg = "no marker found"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self._read_dpi_from_exif()
|
|
||||||
|
|
||||||
def __getstate__(self) -> list[Any]:
|
|
||||||
return super().__getstate__() + [self.layers, self.layer]
|
|
||||||
|
|
||||||
def __setstate__(self, state: list[Any]) -> None:
|
|
||||||
self.layers, self.layer = state[6:]
|
|
||||||
super().__setstate__(state)
|
|
||||||
|
|
||||||
def load_read(self, read_bytes: int) -> bytes:
|
|
||||||
"""
|
|
||||||
internal: read more image data
|
|
||||||
For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker
|
|
||||||
so libjpeg can finish decoding
|
|
||||||
"""
|
|
||||||
assert self.fp is not None
|
|
||||||
s = self.fp.read(read_bytes)
|
|
||||||
|
|
||||||
if not s and ImageFile.LOAD_TRUNCATED_IMAGES and not hasattr(self, "_ended"):
|
|
||||||
# Premature EOF.
|
|
||||||
# Pretend file is finished adding EOI marker
|
|
||||||
self._ended = True
|
|
||||||
return b"\xff\xd9"
|
|
||||||
|
|
||||||
return s
|
|
||||||
|
|
||||||
def draft(
|
|
||||||
self, mode: str | None, size: tuple[int, int] | None
|
|
||||||
) -> tuple[str, tuple[int, int, float, float]] | None:
|
|
||||||
if len(self.tile) != 1:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Protect from second call
|
|
||||||
if self.decoderconfig:
|
|
||||||
return None
|
|
||||||
|
|
||||||
d, e, o, a = self.tile[0]
|
|
||||||
scale = 1
|
|
||||||
original_size = self.size
|
|
||||||
|
|
||||||
assert isinstance(a, tuple)
|
|
||||||
if a[0] == "RGB" and mode in ["L", "YCbCr"]:
|
|
||||||
self._mode = mode
|
|
||||||
a = mode, ""
|
|
||||||
|
|
||||||
if size:
|
|
||||||
scale = min(self.size[0] // size[0], self.size[1] // size[1])
|
|
||||||
for s in [8, 4, 2, 1]:
|
|
||||||
if scale >= s:
|
|
||||||
break
|
|
||||||
assert e is not None
|
|
||||||
e = (
|
|
||||||
e[0],
|
|
||||||
e[1],
|
|
||||||
(e[2] - e[0] + s - 1) // s + e[0],
|
|
||||||
(e[3] - e[1] + s - 1) // s + e[1],
|
|
||||||
)
|
|
||||||
self._size = ((self.size[0] + s - 1) // s, (self.size[1] + s - 1) // s)
|
|
||||||
scale = s
|
|
||||||
|
|
||||||
self.tile = [ImageFile._Tile(d, e, o, a)]
|
|
||||||
self.decoderconfig = (scale, 0)
|
|
||||||
|
|
||||||
box = (0, 0, original_size[0] / scale, original_size[1] / scale)
|
|
||||||
return self.mode, box
|
|
||||||
|
|
||||||
def load_djpeg(self) -> None:
|
|
||||||
# ALTERNATIVE: handle JPEGs via the IJG command line utilities
|
|
||||||
|
|
||||||
f, path = tempfile.mkstemp()
|
|
||||||
os.close(f)
|
|
||||||
if os.path.exists(self.filename):
|
|
||||||
subprocess.check_call(["djpeg", "-outfile", path, self.filename])
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
os.unlink(path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
msg = "Invalid Filename"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with Image.open(path) as _im:
|
|
||||||
_im.load()
|
|
||||||
self.im = _im.im
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
os.unlink(path)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self._mode = self.im.mode
|
|
||||||
self._size = self.im.size
|
|
||||||
|
|
||||||
self.tile = []
|
|
||||||
|
|
||||||
def _getexif(self) -> dict[int, Any] | None:
|
|
||||||
return _getexif(self)
|
|
||||||
|
|
||||||
def _read_dpi_from_exif(self) -> None:
|
|
||||||
# If DPI isn't in JPEG header, fetch from EXIF
|
|
||||||
if "dpi" in self.info or "exif" not in self.info:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
exif = self.getexif()
|
|
||||||
resolution_unit = exif[0x0128]
|
|
||||||
x_resolution = exif[0x011A]
|
|
||||||
try:
|
|
||||||
dpi = float(x_resolution[0]) / x_resolution[1]
|
|
||||||
except TypeError:
|
|
||||||
dpi = x_resolution
|
|
||||||
if math.isnan(dpi):
|
|
||||||
msg = "DPI is not a number"
|
|
||||||
raise ValueError(msg)
|
|
||||||
if resolution_unit == 3: # cm
|
|
||||||
# 1 dpcm = 2.54 dpi
|
|
||||||
dpi *= 2.54
|
|
||||||
self.info["dpi"] = dpi, dpi
|
|
||||||
except (
|
|
||||||
struct.error, # truncated EXIF
|
|
||||||
KeyError, # dpi not included
|
|
||||||
SyntaxError, # invalid/unreadable EXIF
|
|
||||||
TypeError, # dpi is an invalid float
|
|
||||||
ValueError, # dpi is an invalid float
|
|
||||||
ZeroDivisionError, # invalid dpi rational value
|
|
||||||
):
|
|
||||||
self.info["dpi"] = 72, 72
|
|
||||||
|
|
||||||
def _getmp(self) -> dict[int, Any] | None:
|
|
||||||
return _getmp(self)
|
|
||||||
|
|
||||||
|
|
||||||
def _getexif(self: JpegImageFile) -> dict[int, Any] | None:
|
|
||||||
if "exif" not in self.info:
|
|
||||||
return None
|
|
||||||
return self.getexif()._get_merged_dict()
|
|
||||||
|
|
||||||
|
|
||||||
def _getmp(self: JpegImageFile) -> dict[int, Any] | None:
|
|
||||||
# Extract MP information. This method was inspired by the "highly
|
|
||||||
# experimental" _getexif version that's been in use for years now,
|
|
||||||
# itself based on the ImageFileDirectory class in the TIFF plugin.
|
|
||||||
|
|
||||||
# The MP record essentially consists of a TIFF file embedded in a JPEG
|
|
||||||
# application marker.
|
|
||||||
try:
|
|
||||||
data = self.info["mp"]
|
|
||||||
except KeyError:
|
|
||||||
return None
|
|
||||||
file_contents = io.BytesIO(data)
|
|
||||||
head = file_contents.read(8)
|
|
||||||
endianness = ">" if head.startswith(b"\x4d\x4d\x00\x2a") else "<"
|
|
||||||
# process dictionary
|
|
||||||
from . import TiffImagePlugin
|
|
||||||
|
|
||||||
try:
|
|
||||||
info = TiffImagePlugin.ImageFileDirectory_v2(head)
|
|
||||||
file_contents.seek(info.next)
|
|
||||||
info.load(file_contents)
|
|
||||||
mp = dict(info)
|
|
||||||
except Exception as e:
|
|
||||||
msg = "malformed MP Index (unreadable directory)"
|
|
||||||
raise SyntaxError(msg) from e
|
|
||||||
# it's an error not to have a number of images
|
|
||||||
try:
|
|
||||||
quant = mp[0xB001]
|
|
||||||
except KeyError as e:
|
|
||||||
msg = "malformed MP Index (no number of images)"
|
|
||||||
raise SyntaxError(msg) from e
|
|
||||||
# get MP entries
|
|
||||||
mpentries = []
|
|
||||||
try:
|
|
||||||
rawmpentries = mp[0xB002]
|
|
||||||
for entrynum in range(quant):
|
|
||||||
unpackedentry = struct.unpack_from(
|
|
||||||
f"{endianness}LLLHH", rawmpentries, entrynum * 16
|
|
||||||
)
|
|
||||||
labels = ("Attribute", "Size", "DataOffset", "EntryNo1", "EntryNo2")
|
|
||||||
mpentry = dict(zip(labels, unpackedentry))
|
|
||||||
mpentryattr = {
|
|
||||||
"DependentParentImageFlag": bool(mpentry["Attribute"] & (1 << 31)),
|
|
||||||
"DependentChildImageFlag": bool(mpentry["Attribute"] & (1 << 30)),
|
|
||||||
"RepresentativeImageFlag": bool(mpentry["Attribute"] & (1 << 29)),
|
|
||||||
"Reserved": (mpentry["Attribute"] & (3 << 27)) >> 27,
|
|
||||||
"ImageDataFormat": (mpentry["Attribute"] & (7 << 24)) >> 24,
|
|
||||||
"MPType": mpentry["Attribute"] & 0x00FFFFFF,
|
|
||||||
}
|
|
||||||
if mpentryattr["ImageDataFormat"] == 0:
|
|
||||||
mpentryattr["ImageDataFormat"] = "JPEG"
|
|
||||||
else:
|
|
||||||
msg = "unsupported picture format in MPO"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
mptypemap = {
|
|
||||||
0x000000: "Undefined",
|
|
||||||
0x010001: "Large Thumbnail (VGA Equivalent)",
|
|
||||||
0x010002: "Large Thumbnail (Full HD Equivalent)",
|
|
||||||
0x020001: "Multi-Frame Image (Panorama)",
|
|
||||||
0x020002: "Multi-Frame Image: (Disparity)",
|
|
||||||
0x020003: "Multi-Frame Image: (Multi-Angle)",
|
|
||||||
0x030000: "Baseline MP Primary Image",
|
|
||||||
}
|
|
||||||
mpentryattr["MPType"] = mptypemap.get(mpentryattr["MPType"], "Unknown")
|
|
||||||
mpentry["Attribute"] = mpentryattr
|
|
||||||
mpentries.append(mpentry)
|
|
||||||
mp[0xB002] = mpentries
|
|
||||||
except KeyError as e:
|
|
||||||
msg = "malformed MP Index (bad MP Entry)"
|
|
||||||
raise SyntaxError(msg) from e
|
|
||||||
# Next we should try and parse the individual image unique ID list;
|
|
||||||
# we don't because I've never seen this actually used in a real MPO
|
|
||||||
# file and so can't test it.
|
|
||||||
return mp
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# stuff to save JPEG files
|
|
||||||
|
|
||||||
RAWMODE = {
|
|
||||||
"1": "L",
|
|
||||||
"L": "L",
|
|
||||||
"RGB": "RGB",
|
|
||||||
"RGBX": "RGB",
|
|
||||||
"CMYK": "CMYK;I", # assume adobe conventions
|
|
||||||
"YCbCr": "YCbCr",
|
|
||||||
}
|
|
||||||
|
|
||||||
# fmt: off
|
|
||||||
zigzag_index = (
|
|
||||||
0, 1, 5, 6, 14, 15, 27, 28,
|
|
||||||
2, 4, 7, 13, 16, 26, 29, 42,
|
|
||||||
3, 8, 12, 17, 25, 30, 41, 43,
|
|
||||||
9, 11, 18, 24, 31, 40, 44, 53,
|
|
||||||
10, 19, 23, 32, 39, 45, 52, 54,
|
|
||||||
20, 22, 33, 38, 46, 51, 55, 60,
|
|
||||||
21, 34, 37, 47, 50, 56, 59, 61,
|
|
||||||
35, 36, 48, 49, 57, 58, 62, 63,
|
|
||||||
)
|
|
||||||
|
|
||||||
samplings = {
|
|
||||||
(1, 1, 1, 1, 1, 1): 0,
|
|
||||||
(2, 1, 1, 1, 1, 1): 1,
|
|
||||||
(2, 2, 1, 1, 1, 1): 2,
|
|
||||||
}
|
|
||||||
# fmt: on
|
|
||||||
|
|
||||||
|
|
||||||
def get_sampling(im: Image.Image) -> int:
|
|
||||||
# There's no subsampling when images have only 1 layer
|
|
||||||
# (grayscale images) or when they are CMYK (4 layers),
|
|
||||||
# so set subsampling to the default value.
|
|
||||||
#
|
|
||||||
# NOTE: currently Pillow can't encode JPEG to YCCK format.
|
|
||||||
# If YCCK support is added in the future, subsampling code will have
|
|
||||||
# to be updated (here and in JpegEncode.c) to deal with 4 layers.
|
|
||||||
if not isinstance(im, JpegImageFile) or im.layers in (1, 4):
|
|
||||||
return -1
|
|
||||||
sampling = im.layer[0][1:3] + im.layer[1][1:3] + im.layer[2][1:3]
|
|
||||||
return samplings.get(sampling, -1)
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
try:
|
|
||||||
rawmode = RAWMODE[im.mode]
|
|
||||||
except KeyError as e:
|
|
||||||
msg = f"cannot write mode {im.mode} as JPEG"
|
|
||||||
raise OSError(msg) from e
|
|
||||||
|
|
||||||
info = im.encoderinfo
|
|
||||||
|
|
||||||
dpi = [round(x) for x in info.get("dpi", (0, 0))]
|
|
||||||
|
|
||||||
quality = info.get("quality", -1)
|
|
||||||
subsampling = info.get("subsampling", -1)
|
|
||||||
qtables = info.get("qtables")
|
|
||||||
|
|
||||||
if quality == "keep":
|
|
||||||
quality = -1
|
|
||||||
subsampling = "keep"
|
|
||||||
qtables = "keep"
|
|
||||||
elif quality in presets:
|
|
||||||
preset = presets[quality]
|
|
||||||
quality = -1
|
|
||||||
subsampling = preset.get("subsampling", -1)
|
|
||||||
qtables = preset.get("quantization")
|
|
||||||
elif not isinstance(quality, int):
|
|
||||||
msg = "Invalid quality setting"
|
|
||||||
raise ValueError(msg)
|
|
||||||
else:
|
|
||||||
if subsampling in presets:
|
|
||||||
subsampling = presets[subsampling].get("subsampling", -1)
|
|
||||||
if isinstance(qtables, str) and qtables in presets:
|
|
||||||
qtables = presets[qtables].get("quantization")
|
|
||||||
|
|
||||||
if subsampling == "4:4:4":
|
|
||||||
subsampling = 0
|
|
||||||
elif subsampling == "4:2:2":
|
|
||||||
subsampling = 1
|
|
||||||
elif subsampling == "4:2:0":
|
|
||||||
subsampling = 2
|
|
||||||
elif subsampling == "4:1:1":
|
|
||||||
# For compatibility. Before Pillow 4.3, 4:1:1 actually meant 4:2:0.
|
|
||||||
# Set 4:2:0 if someone is still using that value.
|
|
||||||
subsampling = 2
|
|
||||||
elif subsampling == "keep":
|
|
||||||
if im.format != "JPEG":
|
|
||||||
msg = "Cannot use 'keep' when original image is not a JPEG"
|
|
||||||
raise ValueError(msg)
|
|
||||||
subsampling = get_sampling(im)
|
|
||||||
|
|
||||||
def validate_qtables(
|
|
||||||
qtables: (
|
|
||||||
str | tuple[list[int], ...] | list[list[int]] | dict[int, list[int]] | None
|
|
||||||
),
|
|
||||||
) -> list[list[int]] | None:
|
|
||||||
if qtables is None:
|
|
||||||
return qtables
|
|
||||||
if isinstance(qtables, str):
|
|
||||||
try:
|
|
||||||
lines = [
|
|
||||||
int(num)
|
|
||||||
for line in qtables.splitlines()
|
|
||||||
for num in line.split("#", 1)[0].split()
|
|
||||||
]
|
|
||||||
except ValueError as e:
|
|
||||||
msg = "Invalid quantization table"
|
|
||||||
raise ValueError(msg) from e
|
|
||||||
else:
|
|
||||||
qtables = [lines[s : s + 64] for s in range(0, len(lines), 64)]
|
|
||||||
if isinstance(qtables, (tuple, list, dict)):
|
|
||||||
if isinstance(qtables, dict):
|
|
||||||
qtables = [
|
|
||||||
qtables[key] for key in range(len(qtables)) if key in qtables
|
|
||||||
]
|
|
||||||
elif isinstance(qtables, tuple):
|
|
||||||
qtables = list(qtables)
|
|
||||||
if not (0 < len(qtables) < 5):
|
|
||||||
msg = "None or too many quantization tables"
|
|
||||||
raise ValueError(msg)
|
|
||||||
try:
|
|
||||||
for idx, table in enumerate(qtables):
|
|
||||||
if len(table) != 64:
|
|
||||||
msg = "Invalid quantization table"
|
|
||||||
raise TypeError(msg)
|
|
||||||
qtables[idx] = list(array.array("H", table))
|
|
||||||
except TypeError as e:
|
|
||||||
msg = "Invalid quantization table"
|
|
||||||
raise ValueError(msg) from e
|
|
||||||
return qtables
|
|
||||||
|
|
||||||
if qtables == "keep":
|
|
||||||
if im.format != "JPEG":
|
|
||||||
msg = "Cannot use 'keep' when original image is not a JPEG"
|
|
||||||
raise ValueError(msg)
|
|
||||||
qtables = getattr(im, "quantization", None)
|
|
||||||
qtables = validate_qtables(qtables)
|
|
||||||
|
|
||||||
extra = info.get("extra", b"")
|
|
||||||
|
|
||||||
MAX_BYTES_IN_MARKER = 65533
|
|
||||||
if xmp := info.get("xmp"):
|
|
||||||
overhead_len = 29 # b"http://ns.adobe.com/xap/1.0/\x00"
|
|
||||||
max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len
|
|
||||||
if len(xmp) > max_data_bytes_in_marker:
|
|
||||||
msg = "XMP data is too long"
|
|
||||||
raise ValueError(msg)
|
|
||||||
size = o16(2 + overhead_len + len(xmp))
|
|
||||||
extra += b"\xff\xe1" + size + b"http://ns.adobe.com/xap/1.0/\x00" + xmp
|
|
||||||
|
|
||||||
if icc_profile := info.get("icc_profile"):
|
|
||||||
overhead_len = 14 # b"ICC_PROFILE\0" + o8(i) + o8(len(markers))
|
|
||||||
max_data_bytes_in_marker = MAX_BYTES_IN_MARKER - overhead_len
|
|
||||||
markers = []
|
|
||||||
while icc_profile:
|
|
||||||
markers.append(icc_profile[:max_data_bytes_in_marker])
|
|
||||||
icc_profile = icc_profile[max_data_bytes_in_marker:]
|
|
||||||
i = 1
|
|
||||||
for marker in markers:
|
|
||||||
size = o16(2 + overhead_len + len(marker))
|
|
||||||
extra += (
|
|
||||||
b"\xff\xe2"
|
|
||||||
+ size
|
|
||||||
+ b"ICC_PROFILE\0"
|
|
||||||
+ o8(i)
|
|
||||||
+ o8(len(markers))
|
|
||||||
+ marker
|
|
||||||
)
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
comment = info.get("comment", im.info.get("comment"))
|
|
||||||
|
|
||||||
# "progressive" is the official name, but older documentation
|
|
||||||
# says "progression"
|
|
||||||
# FIXME: issue a warning if the wrong form is used (post-1.1.7)
|
|
||||||
progressive = info.get("progressive", False) or info.get("progression", False)
|
|
||||||
|
|
||||||
optimize = info.get("optimize", False)
|
|
||||||
|
|
||||||
exif = info.get("exif", b"")
|
|
||||||
if isinstance(exif, Image.Exif):
|
|
||||||
exif = exif.tobytes()
|
|
||||||
if len(exif) > MAX_BYTES_IN_MARKER:
|
|
||||||
msg = "EXIF data is too long"
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
# get keyword arguments
|
|
||||||
im.encoderconfig = (
|
|
||||||
quality,
|
|
||||||
progressive,
|
|
||||||
info.get("smooth", 0),
|
|
||||||
optimize,
|
|
||||||
info.get("keep_rgb", False),
|
|
||||||
info.get("streamtype", 0),
|
|
||||||
dpi,
|
|
||||||
subsampling,
|
|
||||||
info.get("restart_marker_blocks", 0),
|
|
||||||
info.get("restart_marker_rows", 0),
|
|
||||||
qtables,
|
|
||||||
comment,
|
|
||||||
extra,
|
|
||||||
exif,
|
|
||||||
)
|
|
||||||
|
|
||||||
# if we optimize, libjpeg needs a buffer big enough to hold the whole image
|
|
||||||
# in a shot. Guessing on the size, at im.size bytes. (raw pixel size is
|
|
||||||
# channels*size, this is a value that's been used in a django patch.
|
|
||||||
# https://github.com/matthewwithanm/django-imagekit/issues/50
|
|
||||||
if optimize or progressive:
|
|
||||||
# CMYK can be bigger
|
|
||||||
if im.mode == "CMYK":
|
|
||||||
bufsize = 4 * im.size[0] * im.size[1]
|
|
||||||
# keep sets quality to -1, but the actual value may be high.
|
|
||||||
elif quality >= 95 or quality == -1:
|
|
||||||
bufsize = 2 * im.size[0] * im.size[1]
|
|
||||||
else:
|
|
||||||
bufsize = im.size[0] * im.size[1]
|
|
||||||
if exif:
|
|
||||||
bufsize += len(exif) + 5
|
|
||||||
if extra:
|
|
||||||
bufsize += len(extra) + 1
|
|
||||||
else:
|
|
||||||
# The EXIF info needs to be written as one block, + APP1, + one spare byte.
|
|
||||||
# Ensure that our buffer is big enough. Same with the icc_profile block.
|
|
||||||
bufsize = max(len(exif) + 5, len(extra) + 1)
|
|
||||||
|
|
||||||
ImageFile._save(
|
|
||||||
im, fp, [ImageFile._Tile("jpeg", (0, 0) + im.size, 0, rawmode)], bufsize
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Factory for making JPEG and MPO instances
|
|
||||||
def jpeg_factory(
|
|
||||||
fp: IO[bytes], filename: str | bytes | None = None
|
|
||||||
) -> JpegImageFile | MpoImageFile:
|
|
||||||
im = JpegImageFile(fp, filename)
|
|
||||||
try:
|
|
||||||
mpheader = im._getmp()
|
|
||||||
if mpheader is not None and mpheader[45057] > 1:
|
|
||||||
for segment, content in im.applist:
|
|
||||||
if segment == "APP1" and b' hdrgm:Version="' in content:
|
|
||||||
# Ultra HDR images are not yet supported
|
|
||||||
return im
|
|
||||||
# It's actually an MPO
|
|
||||||
from .MpoImagePlugin import MpoImageFile
|
|
||||||
|
|
||||||
# Don't reload everything, just convert it.
|
|
||||||
im = MpoImageFile.adopt(im, mpheader)
|
|
||||||
except (TypeError, IndexError):
|
|
||||||
# It is really a JPEG
|
|
||||||
pass
|
|
||||||
except SyntaxError:
|
|
||||||
warnings.warn(
|
|
||||||
"Image appears to be a malformed MPO file, it will be "
|
|
||||||
"interpreted as a base JPEG file"
|
|
||||||
)
|
|
||||||
return im
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# Registry stuff
|
|
||||||
|
|
||||||
Image.register_open(JpegImageFile.format, jpeg_factory, _accept)
|
|
||||||
Image.register_save(JpegImageFile.format, _save)
|
|
||||||
|
|
||||||
Image.register_extensions(JpegImageFile.format, [".jfif", ".jpe", ".jpg", ".jpeg"])
|
|
||||||
|
|
||||||
Image.register_mime(JpegImageFile.format, "image/jpeg")
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
"""
|
|
||||||
JPEG quality settings equivalent to the Photoshop settings.
|
|
||||||
Can be used when saving JPEG files.
|
|
||||||
|
|
||||||
The following presets are available by default:
|
|
||||||
``web_low``, ``web_medium``, ``web_high``, ``web_very_high``, ``web_maximum``,
|
|
||||||
``low``, ``medium``, ``high``, ``maximum``.
|
|
||||||
More presets can be added to the :py:data:`presets` dict if needed.
|
|
||||||
|
|
||||||
To apply the preset, specify::
|
|
||||||
|
|
||||||
quality="preset_name"
|
|
||||||
|
|
||||||
To apply only the quantization table::
|
|
||||||
|
|
||||||
qtables="preset_name"
|
|
||||||
|
|
||||||
To apply only the subsampling setting::
|
|
||||||
|
|
||||||
subsampling="preset_name"
|
|
||||||
|
|
||||||
Example::
|
|
||||||
|
|
||||||
im.save("image_name.jpg", quality="web_high")
|
|
||||||
|
|
||||||
Subsampling
|
|
||||||
-----------
|
|
||||||
|
|
||||||
Subsampling is the practice of encoding images by implementing less resolution
|
|
||||||
for chroma information than for luma information.
|
|
||||||
(ref.: https://en.wikipedia.org/wiki/Chroma_subsampling)
|
|
||||||
|
|
||||||
Possible subsampling values are 0, 1 and 2 that correspond to 4:4:4, 4:2:2 and
|
|
||||||
4:2:0.
|
|
||||||
|
|
||||||
You can get the subsampling of a JPEG with the
|
|
||||||
:func:`.JpegImagePlugin.get_sampling` function.
|
|
||||||
|
|
||||||
In JPEG compressed data a JPEG marker is used instead of an EXIF tag.
|
|
||||||
(ref.: https://exiv2.org/tags.html)
|
|
||||||
|
|
||||||
|
|
||||||
Quantization tables
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
They are values use by the DCT (Discrete cosine transform) to remove
|
|
||||||
*unnecessary* information from the image (the lossy part of the compression).
|
|
||||||
(ref.: https://en.wikipedia.org/wiki/Quantization_matrix#Quantization_matrices,
|
|
||||||
https://en.wikipedia.org/wiki/JPEG#Quantization)
|
|
||||||
|
|
||||||
You can get the quantization tables of a JPEG with::
|
|
||||||
|
|
||||||
im.quantization
|
|
||||||
|
|
||||||
This will return a dict with a number of lists. You can pass this dict
|
|
||||||
directly as the qtables argument when saving a JPEG.
|
|
||||||
|
|
||||||
The quantization table format in presets is a list with sublists. These formats
|
|
||||||
are interchangeable.
|
|
||||||
|
|
||||||
Libjpeg ref.:
|
|
||||||
https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
# fmt: off
|
|
||||||
presets = {
|
|
||||||
'web_low': {'subsampling': 2, # "4:2:0"
|
|
||||||
'quantization': [
|
|
||||||
[20, 16, 25, 39, 50, 46, 62, 68,
|
|
||||||
16, 18, 23, 38, 38, 53, 65, 68,
|
|
||||||
25, 23, 31, 38, 53, 65, 68, 68,
|
|
||||||
39, 38, 38, 53, 65, 68, 68, 68,
|
|
||||||
50, 38, 53, 65, 68, 68, 68, 68,
|
|
||||||
46, 53, 65, 68, 68, 68, 68, 68,
|
|
||||||
62, 65, 68, 68, 68, 68, 68, 68,
|
|
||||||
68, 68, 68, 68, 68, 68, 68, 68],
|
|
||||||
[21, 25, 32, 38, 54, 68, 68, 68,
|
|
||||||
25, 28, 24, 38, 54, 68, 68, 68,
|
|
||||||
32, 24, 32, 43, 66, 68, 68, 68,
|
|
||||||
38, 38, 43, 53, 68, 68, 68, 68,
|
|
||||||
54, 54, 66, 68, 68, 68, 68, 68,
|
|
||||||
68, 68, 68, 68, 68, 68, 68, 68,
|
|
||||||
68, 68, 68, 68, 68, 68, 68, 68,
|
|
||||||
68, 68, 68, 68, 68, 68, 68, 68]
|
|
||||||
]},
|
|
||||||
'web_medium': {'subsampling': 2, # "4:2:0"
|
|
||||||
'quantization': [
|
|
||||||
[16, 11, 11, 16, 23, 27, 31, 30,
|
|
||||||
11, 12, 12, 15, 20, 23, 23, 30,
|
|
||||||
11, 12, 13, 16, 23, 26, 35, 47,
|
|
||||||
16, 15, 16, 23, 26, 37, 47, 64,
|
|
||||||
23, 20, 23, 26, 39, 51, 64, 64,
|
|
||||||
27, 23, 26, 37, 51, 64, 64, 64,
|
|
||||||
31, 23, 35, 47, 64, 64, 64, 64,
|
|
||||||
30, 30, 47, 64, 64, 64, 64, 64],
|
|
||||||
[17, 15, 17, 21, 20, 26, 38, 48,
|
|
||||||
15, 19, 18, 17, 20, 26, 35, 43,
|
|
||||||
17, 18, 20, 22, 26, 30, 46, 53,
|
|
||||||
21, 17, 22, 28, 30, 39, 53, 64,
|
|
||||||
20, 20, 26, 30, 39, 48, 64, 64,
|
|
||||||
26, 26, 30, 39, 48, 63, 64, 64,
|
|
||||||
38, 35, 46, 53, 64, 64, 64, 64,
|
|
||||||
48, 43, 53, 64, 64, 64, 64, 64]
|
|
||||||
]},
|
|
||||||
'web_high': {'subsampling': 0, # "4:4:4"
|
|
||||||
'quantization': [
|
|
||||||
[6, 4, 4, 6, 9, 11, 12, 16,
|
|
||||||
4, 5, 5, 6, 8, 10, 12, 12,
|
|
||||||
4, 5, 5, 6, 10, 12, 14, 19,
|
|
||||||
6, 6, 6, 11, 12, 15, 19, 28,
|
|
||||||
9, 8, 10, 12, 16, 20, 27, 31,
|
|
||||||
11, 10, 12, 15, 20, 27, 31, 31,
|
|
||||||
12, 12, 14, 19, 27, 31, 31, 31,
|
|
||||||
16, 12, 19, 28, 31, 31, 31, 31],
|
|
||||||
[7, 7, 13, 24, 26, 31, 31, 31,
|
|
||||||
7, 12, 16, 21, 31, 31, 31, 31,
|
|
||||||
13, 16, 17, 31, 31, 31, 31, 31,
|
|
||||||
24, 21, 31, 31, 31, 31, 31, 31,
|
|
||||||
26, 31, 31, 31, 31, 31, 31, 31,
|
|
||||||
31, 31, 31, 31, 31, 31, 31, 31,
|
|
||||||
31, 31, 31, 31, 31, 31, 31, 31,
|
|
||||||
31, 31, 31, 31, 31, 31, 31, 31]
|
|
||||||
]},
|
|
||||||
'web_very_high': {'subsampling': 0, # "4:4:4"
|
|
||||||
'quantization': [
|
|
||||||
[2, 2, 2, 2, 3, 4, 5, 6,
|
|
||||||
2, 2, 2, 2, 3, 4, 5, 6,
|
|
||||||
2, 2, 2, 2, 4, 5, 7, 9,
|
|
||||||
2, 2, 2, 4, 5, 7, 9, 12,
|
|
||||||
3, 3, 4, 5, 8, 10, 12, 12,
|
|
||||||
4, 4, 5, 7, 10, 12, 12, 12,
|
|
||||||
5, 5, 7, 9, 12, 12, 12, 12,
|
|
||||||
6, 6, 9, 12, 12, 12, 12, 12],
|
|
||||||
[3, 3, 5, 9, 13, 15, 15, 15,
|
|
||||||
3, 4, 6, 11, 14, 12, 12, 12,
|
|
||||||
5, 6, 9, 14, 12, 12, 12, 12,
|
|
||||||
9, 11, 14, 12, 12, 12, 12, 12,
|
|
||||||
13, 14, 12, 12, 12, 12, 12, 12,
|
|
||||||
15, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
15, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
15, 12, 12, 12, 12, 12, 12, 12]
|
|
||||||
]},
|
|
||||||
'web_maximum': {'subsampling': 0, # "4:4:4"
|
|
||||||
'quantization': [
|
|
||||||
[1, 1, 1, 1, 1, 1, 1, 1,
|
|
||||||
1, 1, 1, 1, 1, 1, 1, 1,
|
|
||||||
1, 1, 1, 1, 1, 1, 1, 2,
|
|
||||||
1, 1, 1, 1, 1, 1, 2, 2,
|
|
||||||
1, 1, 1, 1, 1, 2, 2, 3,
|
|
||||||
1, 1, 1, 1, 2, 2, 3, 3,
|
|
||||||
1, 1, 1, 2, 2, 3, 3, 3,
|
|
||||||
1, 1, 2, 2, 3, 3, 3, 3],
|
|
||||||
[1, 1, 1, 2, 2, 3, 3, 3,
|
|
||||||
1, 1, 1, 2, 3, 3, 3, 3,
|
|
||||||
1, 1, 1, 3, 3, 3, 3, 3,
|
|
||||||
2, 2, 3, 3, 3, 3, 3, 3,
|
|
||||||
2, 3, 3, 3, 3, 3, 3, 3,
|
|
||||||
3, 3, 3, 3, 3, 3, 3, 3,
|
|
||||||
3, 3, 3, 3, 3, 3, 3, 3,
|
|
||||||
3, 3, 3, 3, 3, 3, 3, 3]
|
|
||||||
]},
|
|
||||||
'low': {'subsampling': 2, # "4:2:0"
|
|
||||||
'quantization': [
|
|
||||||
[18, 14, 14, 21, 30, 35, 34, 17,
|
|
||||||
14, 16, 16, 19, 26, 23, 12, 12,
|
|
||||||
14, 16, 17, 21, 23, 12, 12, 12,
|
|
||||||
21, 19, 21, 23, 12, 12, 12, 12,
|
|
||||||
30, 26, 23, 12, 12, 12, 12, 12,
|
|
||||||
35, 23, 12, 12, 12, 12, 12, 12,
|
|
||||||
34, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
17, 12, 12, 12, 12, 12, 12, 12],
|
|
||||||
[20, 19, 22, 27, 20, 20, 17, 17,
|
|
||||||
19, 25, 23, 14, 14, 12, 12, 12,
|
|
||||||
22, 23, 14, 14, 12, 12, 12, 12,
|
|
||||||
27, 14, 14, 12, 12, 12, 12, 12,
|
|
||||||
20, 14, 12, 12, 12, 12, 12, 12,
|
|
||||||
20, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
17, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
17, 12, 12, 12, 12, 12, 12, 12]
|
|
||||||
]},
|
|
||||||
'medium': {'subsampling': 2, # "4:2:0"
|
|
||||||
'quantization': [
|
|
||||||
[12, 8, 8, 12, 17, 21, 24, 17,
|
|
||||||
8, 9, 9, 11, 15, 19, 12, 12,
|
|
||||||
8, 9, 10, 12, 19, 12, 12, 12,
|
|
||||||
12, 11, 12, 21, 12, 12, 12, 12,
|
|
||||||
17, 15, 19, 12, 12, 12, 12, 12,
|
|
||||||
21, 19, 12, 12, 12, 12, 12, 12,
|
|
||||||
24, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
17, 12, 12, 12, 12, 12, 12, 12],
|
|
||||||
[13, 11, 13, 16, 20, 20, 17, 17,
|
|
||||||
11, 14, 14, 14, 14, 12, 12, 12,
|
|
||||||
13, 14, 14, 14, 12, 12, 12, 12,
|
|
||||||
16, 14, 14, 12, 12, 12, 12, 12,
|
|
||||||
20, 14, 12, 12, 12, 12, 12, 12,
|
|
||||||
20, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
17, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
17, 12, 12, 12, 12, 12, 12, 12]
|
|
||||||
]},
|
|
||||||
'high': {'subsampling': 0, # "4:4:4"
|
|
||||||
'quantization': [
|
|
||||||
[6, 4, 4, 6, 9, 11, 12, 16,
|
|
||||||
4, 5, 5, 6, 8, 10, 12, 12,
|
|
||||||
4, 5, 5, 6, 10, 12, 12, 12,
|
|
||||||
6, 6, 6, 11, 12, 12, 12, 12,
|
|
||||||
9, 8, 10, 12, 12, 12, 12, 12,
|
|
||||||
11, 10, 12, 12, 12, 12, 12, 12,
|
|
||||||
12, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
16, 12, 12, 12, 12, 12, 12, 12],
|
|
||||||
[7, 7, 13, 24, 20, 20, 17, 17,
|
|
||||||
7, 12, 16, 14, 14, 12, 12, 12,
|
|
||||||
13, 16, 14, 14, 12, 12, 12, 12,
|
|
||||||
24, 14, 14, 12, 12, 12, 12, 12,
|
|
||||||
20, 14, 12, 12, 12, 12, 12, 12,
|
|
||||||
20, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
17, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
17, 12, 12, 12, 12, 12, 12, 12]
|
|
||||||
]},
|
|
||||||
'maximum': {'subsampling': 0, # "4:4:4"
|
|
||||||
'quantization': [
|
|
||||||
[2, 2, 2, 2, 3, 4, 5, 6,
|
|
||||||
2, 2, 2, 2, 3, 4, 5, 6,
|
|
||||||
2, 2, 2, 2, 4, 5, 7, 9,
|
|
||||||
2, 2, 2, 4, 5, 7, 9, 12,
|
|
||||||
3, 3, 4, 5, 8, 10, 12, 12,
|
|
||||||
4, 4, 5, 7, 10, 12, 12, 12,
|
|
||||||
5, 5, 7, 9, 12, 12, 12, 12,
|
|
||||||
6, 6, 9, 12, 12, 12, 12, 12],
|
|
||||||
[3, 3, 5, 9, 13, 15, 15, 15,
|
|
||||||
3, 4, 6, 10, 14, 12, 12, 12,
|
|
||||||
5, 6, 9, 14, 12, 12, 12, 12,
|
|
||||||
9, 10, 14, 12, 12, 12, 12, 12,
|
|
||||||
13, 14, 12, 12, 12, 12, 12, 12,
|
|
||||||
15, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
15, 12, 12, 12, 12, 12, 12, 12,
|
|
||||||
15, 12, 12, 12, 12, 12, 12, 12]
|
|
||||||
]},
|
|
||||||
}
|
|
||||||
# fmt: on
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# Basic McIdas support for PIL
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 1997-05-05 fl Created (8-bit images only)
|
|
||||||
# 2009-03-08 fl Added 16/32-bit support.
|
|
||||||
#
|
|
||||||
# Thanks to Richard Jones and Craig Swank for specs and samples.
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1997.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import struct
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(b"\x00\x00\x00\x00\x00\x00\x00\x04")
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for McIdas area images.
|
|
||||||
|
|
||||||
|
|
||||||
class McIdasImageFile(ImageFile.ImageFile):
|
|
||||||
format = "MCIDAS"
|
|
||||||
format_description = "McIdas area file"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
# parse area file directory
|
|
||||||
assert self.fp is not None
|
|
||||||
|
|
||||||
s = self.fp.read(256)
|
|
||||||
if not _accept(s) or len(s) != 256:
|
|
||||||
msg = "not an McIdas area file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self.area_descriptor_raw = s
|
|
||||||
self.area_descriptor = w = [0, *struct.unpack("!64i", s)]
|
|
||||||
|
|
||||||
# get mode
|
|
||||||
if w[11] == 1:
|
|
||||||
mode = rawmode = "L"
|
|
||||||
elif w[11] == 2:
|
|
||||||
mode = rawmode = "I;16B"
|
|
||||||
elif w[11] == 4:
|
|
||||||
# FIXME: add memory map support
|
|
||||||
mode = "I"
|
|
||||||
rawmode = "I;32B"
|
|
||||||
else:
|
|
||||||
msg = "unsupported McIdas format"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self._mode = mode
|
|
||||||
self._size = w[10], w[9]
|
|
||||||
|
|
||||||
offset = w[34] + w[15]
|
|
||||||
stride = w[15] + w[10] * w[11] * w[14]
|
|
||||||
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile("raw", (0, 0) + self.size, offset, (rawmode, stride, 1))
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# registry
|
|
||||||
|
|
||||||
Image.register_open(McIdasImageFile.format, McIdasImageFile, _accept)
|
|
||||||
|
|
||||||
# no default extension
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# Microsoft Image Composer support for PIL
|
|
||||||
#
|
|
||||||
# Notes:
|
|
||||||
# uses TiffImagePlugin.py to read the actual image streams
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 97-01-20 fl Created
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1997.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import olefile
|
|
||||||
|
|
||||||
from . import Image, TiffImagePlugin
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(olefile.MAGIC)
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for Microsoft's Image Composer file format.
|
|
||||||
|
|
||||||
|
|
||||||
class MicImageFile(TiffImagePlugin.TiffImageFile):
|
|
||||||
format = "MIC"
|
|
||||||
format_description = "Microsoft Image Composer"
|
|
||||||
_close_exclusive_fp_after_loading = False
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
# read the OLE directory and see if this is a likely
|
|
||||||
# to be a Microsoft Image Composer file
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.ole = olefile.OleFileIO(self.fp)
|
|
||||||
except OSError as e:
|
|
||||||
msg = "not an MIC file; invalid OLE file"
|
|
||||||
raise SyntaxError(msg) from e
|
|
||||||
|
|
||||||
# find ACI subfiles with Image members (maybe not the
|
|
||||||
# best way to identify MIC files, but what the... ;-)
|
|
||||||
|
|
||||||
self.images = [
|
|
||||||
path
|
|
||||||
for path in self.ole.listdir()
|
|
||||||
if path[1:] and path[0].endswith(".ACI") and path[1] == "Image"
|
|
||||||
]
|
|
||||||
|
|
||||||
# if we didn't find any images, this is probably not
|
|
||||||
# an MIC file.
|
|
||||||
if not self.images:
|
|
||||||
msg = "not an MIC file; no image entries"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self.frame = -1
|
|
||||||
self._n_frames = len(self.images)
|
|
||||||
self.is_animated = self._n_frames > 1
|
|
||||||
|
|
||||||
assert self.fp is not None
|
|
||||||
self.__fp = self.fp
|
|
||||||
self.seek(0)
|
|
||||||
|
|
||||||
def seek(self, frame: int) -> None:
|
|
||||||
if not self._seek_check(frame):
|
|
||||||
return
|
|
||||||
filename = self.images[frame]
|
|
||||||
self.fp = self.ole.openstream(filename)
|
|
||||||
|
|
||||||
TiffImagePlugin.TiffImageFile._open(self)
|
|
||||||
|
|
||||||
self.frame = frame
|
|
||||||
|
|
||||||
def tell(self) -> int:
|
|
||||||
return self.frame
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self.__fp.close()
|
|
||||||
self.ole.close()
|
|
||||||
super().close()
|
|
||||||
|
|
||||||
def __exit__(self, *args: object) -> None:
|
|
||||||
self.__fp.close()
|
|
||||||
self.ole.close()
|
|
||||||
super().__exit__()
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
|
|
||||||
Image.register_open(MicImageFile.format, MicImageFile, _accept)
|
|
||||||
|
|
||||||
Image.register_extension(MicImageFile.format, ".mic")
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# MPEG file handling
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 95-09-09 fl Created
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1995.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
from ._binary import i8
|
|
||||||
from ._typing import SupportsRead
|
|
||||||
|
|
||||||
#
|
|
||||||
# Bitstream parser
|
|
||||||
|
|
||||||
|
|
||||||
class BitStream:
|
|
||||||
def __init__(self, fp: SupportsRead[bytes]) -> None:
|
|
||||||
self.fp = fp
|
|
||||||
self.bits = 0
|
|
||||||
self.bitbuffer = 0
|
|
||||||
|
|
||||||
def next(self) -> int:
|
|
||||||
return i8(self.fp.read(1))
|
|
||||||
|
|
||||||
def peek(self, bits: int) -> int:
|
|
||||||
while self.bits < bits:
|
|
||||||
self.bitbuffer = (self.bitbuffer << 8) + self.next()
|
|
||||||
self.bits += 8
|
|
||||||
return self.bitbuffer >> (self.bits - bits) & (1 << bits) - 1
|
|
||||||
|
|
||||||
def skip(self, bits: int) -> None:
|
|
||||||
while self.bits < bits:
|
|
||||||
self.bitbuffer = (self.bitbuffer << 8) + i8(self.fp.read(1))
|
|
||||||
self.bits += 8
|
|
||||||
self.bits = self.bits - bits
|
|
||||||
|
|
||||||
def read(self, bits: int) -> int:
|
|
||||||
v = self.peek(bits)
|
|
||||||
self.bits = self.bits - bits
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith(b"\x00\x00\x01\xb3")
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for MPEG streams. This plugin can identify a stream,
|
|
||||||
# but it cannot read it.
|
|
||||||
|
|
||||||
|
|
||||||
class MpegImageFile(ImageFile.ImageFile):
|
|
||||||
format = "MPEG"
|
|
||||||
format_description = "MPEG"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
|
|
||||||
s = BitStream(self.fp)
|
|
||||||
if s.read(32) != 0x1B3:
|
|
||||||
msg = "not an MPEG file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self._mode = "RGB"
|
|
||||||
self._size = s.read(12), s.read(12)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------
|
|
||||||
# Registry stuff
|
|
||||||
|
|
||||||
Image.register_open(MpegImageFile.format, MpegImageFile, _accept)
|
|
||||||
|
|
||||||
Image.register_extensions(MpegImageFile.format, [".mpg", ".mpeg"])
|
|
||||||
|
|
||||||
Image.register_mime(MpegImageFile.format, "video/mpeg")
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
# $Id$
|
|
||||||
#
|
|
||||||
# MPO file handling
|
|
||||||
#
|
|
||||||
# See "Multi-Picture Format" (CIPA DC-007-Translation 2009, Standard of the
|
|
||||||
# Camera & Imaging Products Association)
|
|
||||||
#
|
|
||||||
# The multi-picture object combines multiple JPEG images (with a modified EXIF
|
|
||||||
# data format) into a single file. While it can theoretically be used much like
|
|
||||||
# a GIF animation, it is commonly used to represent 3D photographs and is (as
|
|
||||||
# of this writing) the most commonly used format by 3D cameras.
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 2014-03-13 Feneric Created
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from typing import IO, Any, cast
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
Image,
|
|
||||||
ImageFile,
|
|
||||||
ImageSequence,
|
|
||||||
JpegImagePlugin,
|
|
||||||
TiffImagePlugin,
|
|
||||||
)
|
|
||||||
from ._binary import o32le
|
|
||||||
from ._util import DeferredError
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
JpegImagePlugin._save(im, fp, filename)
|
|
||||||
|
|
||||||
|
|
||||||
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
append_images = im.encoderinfo.get("append_images", [])
|
|
||||||
if not append_images and not getattr(im, "is_animated", False):
|
|
||||||
_save(im, fp, filename)
|
|
||||||
return
|
|
||||||
|
|
||||||
mpf_offset = 28
|
|
||||||
offsets: list[int] = []
|
|
||||||
im_sequences = [im, *append_images]
|
|
||||||
total = sum(getattr(seq, "n_frames", 1) for seq in im_sequences)
|
|
||||||
for im_sequence in im_sequences:
|
|
||||||
for im_frame in ImageSequence.Iterator(im_sequence):
|
|
||||||
if not offsets:
|
|
||||||
# APP2 marker
|
|
||||||
ifd_length = 66 + 16 * total
|
|
||||||
im_frame.encoderinfo["extra"] = (
|
|
||||||
b"\xff\xe2"
|
|
||||||
+ struct.pack(">H", 6 + ifd_length)
|
|
||||||
+ b"MPF\0"
|
|
||||||
+ b" " * ifd_length
|
|
||||||
)
|
|
||||||
if exif := im_frame.encoderinfo.get("exif"):
|
|
||||||
if isinstance(exif, Image.Exif):
|
|
||||||
exif = exif.tobytes()
|
|
||||||
im_frame.encoderinfo["exif"] = exif
|
|
||||||
mpf_offset += 4 + len(exif)
|
|
||||||
|
|
||||||
JpegImagePlugin._save(im_frame, fp, filename)
|
|
||||||
offsets.append(fp.tell())
|
|
||||||
else:
|
|
||||||
encoderinfo = im_frame._attach_default_encoderinfo(im)
|
|
||||||
im_frame.save(fp, "JPEG")
|
|
||||||
im_frame.encoderinfo = encoderinfo
|
|
||||||
offsets.append(fp.tell() - offsets[-1])
|
|
||||||
|
|
||||||
ifd = TiffImagePlugin.ImageFileDirectory_v2()
|
|
||||||
ifd[0xB000] = b"0100"
|
|
||||||
ifd[0xB001] = len(offsets)
|
|
||||||
|
|
||||||
mpentries = b""
|
|
||||||
data_offset = 0
|
|
||||||
for i, size in enumerate(offsets):
|
|
||||||
if i == 0:
|
|
||||||
mptype = 0x030000 # Baseline MP Primary Image
|
|
||||||
else:
|
|
||||||
mptype = 0x000000 # Undefined
|
|
||||||
mpentries += struct.pack("<LLLHH", mptype, size, data_offset, 0, 0)
|
|
||||||
if i == 0:
|
|
||||||
data_offset -= mpf_offset
|
|
||||||
data_offset += size
|
|
||||||
ifd[0xB002] = mpentries
|
|
||||||
|
|
||||||
fp.seek(mpf_offset)
|
|
||||||
fp.write(b"II\x2a\x00" + o32le(8) + ifd.tobytes(8))
|
|
||||||
fp.seek(0, os.SEEK_END)
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for MPO images.
|
|
||||||
|
|
||||||
|
|
||||||
class MpoImageFile(JpegImagePlugin.JpegImageFile):
|
|
||||||
format = "MPO"
|
|
||||||
format_description = "MPO (CIPA DC-007)"
|
|
||||||
_close_exclusive_fp_after_loading = False
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
assert self.fp is not None
|
|
||||||
self.fp.seek(0) # prep the fp in order to pass the JPEG test
|
|
||||||
JpegImagePlugin.JpegImageFile._open(self)
|
|
||||||
self._after_jpeg_open()
|
|
||||||
|
|
||||||
def _after_jpeg_open(self, mpheader: dict[int, Any] | None = None) -> None:
|
|
||||||
self.mpinfo = mpheader if mpheader is not None else self._getmp()
|
|
||||||
if self.mpinfo is None:
|
|
||||||
msg = "Image appears to be a malformed MPO file"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.n_frames = self.mpinfo[0xB001]
|
|
||||||
self.__mpoffsets = [
|
|
||||||
mpent["DataOffset"] + self.info["mpoffset"] for mpent in self.mpinfo[0xB002]
|
|
||||||
]
|
|
||||||
self.__mpoffsets[0] = 0
|
|
||||||
# Note that the following assertion will only be invalid if something
|
|
||||||
# gets broken within JpegImagePlugin.
|
|
||||||
assert self.n_frames == len(self.__mpoffsets)
|
|
||||||
del self.info["mpoffset"] # no longer needed
|
|
||||||
self.is_animated = self.n_frames > 1
|
|
||||||
assert self.fp is not None
|
|
||||||
self._fp = self.fp # FIXME: hack
|
|
||||||
self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame
|
|
||||||
self.__frame = 0
|
|
||||||
self.offset = 0
|
|
||||||
# for now we can only handle reading and individual frame extraction
|
|
||||||
self.readonly = 1
|
|
||||||
|
|
||||||
def load_seek(self, pos: int) -> None:
|
|
||||||
if isinstance(self._fp, DeferredError):
|
|
||||||
raise self._fp.ex
|
|
||||||
self._fp.seek(pos)
|
|
||||||
|
|
||||||
def seek(self, frame: int) -> None:
|
|
||||||
if not self._seek_check(frame):
|
|
||||||
return
|
|
||||||
if isinstance(self._fp, DeferredError):
|
|
||||||
raise self._fp.ex
|
|
||||||
self.fp = self._fp
|
|
||||||
self.offset = self.__mpoffsets[frame]
|
|
||||||
|
|
||||||
original_exif = self.info.get("exif")
|
|
||||||
if "exif" in self.info:
|
|
||||||
del self.info["exif"]
|
|
||||||
|
|
||||||
self.fp.seek(self.offset + 2) # skip SOI marker
|
|
||||||
if not self.fp.read(2):
|
|
||||||
msg = "No data found for frame"
|
|
||||||
raise ValueError(msg)
|
|
||||||
self.fp.seek(self.offset)
|
|
||||||
JpegImagePlugin.JpegImageFile._open(self)
|
|
||||||
if self.info.get("exif") != original_exif:
|
|
||||||
self._reload_exif()
|
|
||||||
|
|
||||||
self.tile = [
|
|
||||||
ImageFile._Tile("jpeg", (0, 0) + self.size, self.offset, self.tile[0][-1])
|
|
||||||
]
|
|
||||||
self.__frame = frame
|
|
||||||
|
|
||||||
def tell(self) -> int:
|
|
||||||
return self.__frame
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def adopt(
|
|
||||||
jpeg_instance: JpegImagePlugin.JpegImageFile,
|
|
||||||
mpheader: dict[int, Any] | None = None,
|
|
||||||
) -> MpoImageFile:
|
|
||||||
"""
|
|
||||||
Transform the instance of JpegImageFile into
|
|
||||||
an instance of MpoImageFile.
|
|
||||||
After the call, the JpegImageFile is extended
|
|
||||||
to be an MpoImageFile.
|
|
||||||
|
|
||||||
This is essentially useful when opening a JPEG
|
|
||||||
file that reveals itself as an MPO, to avoid
|
|
||||||
double call to _open.
|
|
||||||
"""
|
|
||||||
jpeg_instance.__class__ = MpoImageFile
|
|
||||||
mpo_instance = cast(MpoImageFile, jpeg_instance)
|
|
||||||
mpo_instance._after_jpeg_open(mpheader)
|
|
||||||
return mpo_instance
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------
|
|
||||||
# Registry stuff
|
|
||||||
|
|
||||||
# Note that since MPO shares a factory with JPEG, we do not need to do a
|
|
||||||
# separate registration for it here.
|
|
||||||
# Image.register_open(MpoImageFile.format,
|
|
||||||
# JpegImagePlugin.jpeg_factory, _accept)
|
|
||||||
Image.register_save(MpoImageFile.format, _save)
|
|
||||||
Image.register_save_all(MpoImageFile.format, _save_all)
|
|
||||||
|
|
||||||
Image.register_extension(MpoImageFile.format, ".mpo")
|
|
||||||
|
|
||||||
Image.register_mime(MpoImageFile.format, "image/mpo")
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
#
|
|
||||||
# The Python Imaging Library.
|
|
||||||
#
|
|
||||||
# MSP file handling
|
|
||||||
#
|
|
||||||
# This is the format used by the Paint program in Windows 1 and 2.
|
|
||||||
#
|
|
||||||
# History:
|
|
||||||
# 95-09-05 fl Created
|
|
||||||
# 97-01-03 fl Read/write MSP images
|
|
||||||
# 17-02-21 es Fixed RLE interpretation
|
|
||||||
#
|
|
||||||
# Copyright (c) Secret Labs AB 1997.
|
|
||||||
# Copyright (c) Fredrik Lundh 1995-97.
|
|
||||||
# Copyright (c) Eric Soroos 2017.
|
|
||||||
#
|
|
||||||
# See the README file for information on usage and redistribution.
|
|
||||||
#
|
|
||||||
# More info on this format: https://archive.org/details/gg243631
|
|
||||||
# Page 313:
|
|
||||||
# Figure 205. Windows Paint Version 1: "DanM" Format
|
|
||||||
# Figure 206. Windows Paint Version 2: "LinS" Format. Used in Windows V2.03
|
|
||||||
#
|
|
||||||
# See also: https://www.fileformat.info/format/mspaint/egff.htm
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import io
|
|
||||||
import struct
|
|
||||||
from typing import IO
|
|
||||||
|
|
||||||
from . import Image, ImageFile
|
|
||||||
from ._binary import i16le as i16
|
|
||||||
from ._binary import o16le as o16
|
|
||||||
|
|
||||||
#
|
|
||||||
# read MSP files
|
|
||||||
|
|
||||||
|
|
||||||
def _accept(prefix: bytes) -> bool:
|
|
||||||
return prefix.startswith((b"DanM", b"LinS"))
|
|
||||||
|
|
||||||
|
|
||||||
##
|
|
||||||
# Image plugin for Windows MSP images. This plugin supports both
|
|
||||||
# uncompressed (Windows 1.0).
|
|
||||||
|
|
||||||
|
|
||||||
class MspImageFile(ImageFile.ImageFile):
|
|
||||||
format = "MSP"
|
|
||||||
format_description = "Windows Paint"
|
|
||||||
|
|
||||||
def _open(self) -> None:
|
|
||||||
# Header
|
|
||||||
assert self.fp is not None
|
|
||||||
|
|
||||||
s = self.fp.read(32)
|
|
||||||
if not _accept(s):
|
|
||||||
msg = "not an MSP file"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
# Header checksum
|
|
||||||
checksum = 0
|
|
||||||
for i in range(0, 32, 2):
|
|
||||||
checksum = checksum ^ i16(s, i)
|
|
||||||
if checksum != 0:
|
|
||||||
msg = "bad MSP checksum"
|
|
||||||
raise SyntaxError(msg)
|
|
||||||
|
|
||||||
self._mode = "1"
|
|
||||||
self._size = i16(s, 4), i16(s, 6)
|
|
||||||
|
|
||||||
if s.startswith(b"DanM"):
|
|
||||||
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 32, "1")]
|
|
||||||
else:
|
|
||||||
self.tile = [ImageFile._Tile("MSP", (0, 0) + self.size, 32)]
|
|
||||||
|
|
||||||
|
|
||||||
class MspDecoder(ImageFile.PyDecoder):
|
|
||||||
# The algo for the MSP decoder is from
|
|
||||||
# https://www.fileformat.info/format/mspaint/egff.htm
|
|
||||||
# cc-by-attribution -- That page references is taken from the
|
|
||||||
# Encyclopedia of Graphics File Formats and is licensed by
|
|
||||||
# O'Reilly under the Creative Common/Attribution license
|
|
||||||
#
|
|
||||||
# For RLE encoded files, the 32byte header is followed by a scan
|
|
||||||
# line map, encoded as one 16bit word of encoded byte length per
|
|
||||||
# line.
|
|
||||||
#
|
|
||||||
# NOTE: the encoded length of the line can be 0. This was not
|
|
||||||
# handled in the previous version of this encoder, and there's no
|
|
||||||
# mention of how to handle it in the documentation. From the few
|
|
||||||
# examples I've seen, I've assumed that it is a fill of the
|
|
||||||
# background color, in this case, white.
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# Pseudocode of the decoder:
|
|
||||||
# Read a BYTE value as the RunType
|
|
||||||
# If the RunType value is zero
|
|
||||||
# Read next byte as the RunCount
|
|
||||||
# Read the next byte as the RunValue
|
|
||||||
# Write the RunValue byte RunCount times
|
|
||||||
# If the RunType value is non-zero
|
|
||||||
# Use this value as the RunCount
|
|
||||||
# Read and write the next RunCount bytes literally
|
|
||||||
#
|
|
||||||
# e.g.:
|
|
||||||
# 0x00 03 ff 05 00 01 02 03 04
|
|
||||||
# would yield the bytes:
|
|
||||||
# 0xff ff ff 00 01 02 03 04
|
|
||||||
#
|
|
||||||
# which are then interpreted as a bit packed mode '1' image
|
|
||||||
|
|
||||||
_pulls_fd = True
|
|
||||||
|
|
||||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
|
||||||
assert self.fd is not None
|
|
||||||
|
|
||||||
img = io.BytesIO()
|
|
||||||
blank_line = bytearray((0xFF,) * ((self.state.xsize + 7) // 8))
|
|
||||||
try:
|
|
||||||
self.fd.seek(32)
|
|
||||||
rowmap = struct.unpack_from(
|
|
||||||
f"<{self.state.ysize}H", self.fd.read(self.state.ysize * 2)
|
|
||||||
)
|
|
||||||
except struct.error as e:
|
|
||||||
msg = "Truncated MSP file in row map"
|
|
||||||
raise OSError(msg) from e
|
|
||||||
|
|
||||||
for x, rowlen in enumerate(rowmap):
|
|
||||||
try:
|
|
||||||
if rowlen == 0:
|
|
||||||
img.write(blank_line)
|
|
||||||
continue
|
|
||||||
row = self.fd.read(rowlen)
|
|
||||||
if len(row) != rowlen:
|
|
||||||
msg = f"Truncated MSP file, expected {rowlen} bytes on row {x}"
|
|
||||||
raise OSError(msg)
|
|
||||||
idx = 0
|
|
||||||
while idx < rowlen:
|
|
||||||
runtype = row[idx]
|
|
||||||
idx += 1
|
|
||||||
if runtype == 0:
|
|
||||||
runcount, runval = struct.unpack_from("Bc", row, idx)
|
|
||||||
img.write(runval * runcount)
|
|
||||||
idx += 2
|
|
||||||
else:
|
|
||||||
runcount = runtype
|
|
||||||
img.write(row[idx : idx + runcount])
|
|
||||||
idx += runcount
|
|
||||||
|
|
||||||
except struct.error as e:
|
|
||||||
msg = f"Corrupted MSP file in row {x}"
|
|
||||||
raise OSError(msg) from e
|
|
||||||
|
|
||||||
self.set_as_raw(img.getvalue(), "1")
|
|
||||||
|
|
||||||
return -1, 0
|
|
||||||
|
|
||||||
|
|
||||||
Image.register_decoder("MSP", MspDecoder)
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# write MSP files (uncompressed only)
|
|
||||||
|
|
||||||
|
|
||||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
|
||||||
if im.mode != "1":
|
|
||||||
msg = f"cannot write mode {im.mode} as MSP"
|
|
||||||
raise OSError(msg)
|
|
||||||
|
|
||||||
# create MSP header
|
|
||||||
header = [0] * 16
|
|
||||||
|
|
||||||
header[0], header[1] = i16(b"Da"), i16(b"nM") # version 1
|
|
||||||
header[2], header[3] = im.size
|
|
||||||
header[4], header[5] = 1, 1
|
|
||||||
header[6], header[7] = 1, 1
|
|
||||||
header[8], header[9] = im.size
|
|
||||||
|
|
||||||
checksum = 0
|
|
||||||
for h in header:
|
|
||||||
checksum = checksum ^ h
|
|
||||||
header[12] = checksum # FIXME: is this the right field?
|
|
||||||
|
|
||||||
# header
|
|
||||||
for h in header:
|
|
||||||
fp.write(o16(h))
|
|
||||||
|
|
||||||
# image body
|
|
||||||
ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 32, "1")])
|
|
||||||
|
|
||||||
|
|
||||||
#
|
|
||||||
# registry
|
|
||||||
|
|
||||||
Image.register_open(MspImageFile.format, MspImageFile, _accept)
|
|
||||||
Image.register_save(MspImageFile.format, _save)
|
|
||||||
|
|
||||||
Image.register_extension(MspImageFile.format, ".msp")
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user